feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
+128
-134
@@ -3,7 +3,8 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] {
|
||||
return a.map((v, i) => v + (b[i] ?? 0))
|
||||
}
|
||||
|
||||
function fmtMbps(v: number) {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
|
||||
return `${v} Мбит/с`
|
||||
}
|
||||
|
||||
function fmtGB(v: number) {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
|
||||
return `${v.toFixed(1)} ГБ`
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface GreClientTraffic {
|
||||
@@ -318,63 +311,10 @@ function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; he
|
||||
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
|
||||
<path d={area(rx)} fill="hsl(var(--primary))" fillOpacity={0.12} />
|
||||
<polyline points={line(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="#3b82f6" fillOpacity={0.10} />
|
||||
<polyline points={line(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BigChart({ rx, tx }: { rx: number[]; tx: number[] }) {
|
||||
const W = 900, H = 220
|
||||
const pad = { l: 56, r: 16, t: 14, b: 32 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
const maxVal = Math.max(...rx, ...tx, 1) * 1.15
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
const area = (arr: number[]) => {
|
||||
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||||
return `M ${pad.l},${pad.t + ih} L ${pts} L ${pad.l + iw},${pad.t + ih} Z`
|
||||
}
|
||||
const poly = (arr: number[]) => arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const gridVals = [0, 0.25, 0.5, 0.75, 1]
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: H, display: "block" }}>
|
||||
<defs>
|
||||
<linearGradient id="rx-big" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tx-big" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.18" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gridVals.map((p, i) => {
|
||||
const y = pad.t + ih * (1 - p)
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={pad.l} x2={W - pad.r} y1={y} y2={y}
|
||||
stroke="hsl(var(--border))" strokeDasharray={p === 0 ? "0" : "2 4"} />
|
||||
<text x={pad.l - 8} y={y + 4} textAnchor="end" fontSize="10"
|
||||
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
|
||||
{fmtMbps(Math.round(maxVal * p))}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<path d={area(rx)} fill="url(#rx-big)" />
|
||||
<polyline points={poly(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="url(#tx-big)" />
|
||||
<polyline points={poly(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
{[0, 12, 24, 36, 48, 59].map(i => (
|
||||
<text key={i} x={xAt(i, 60)} y={H - 8} textAnchor="middle" fontSize="10"
|
||||
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
|
||||
-{60 - i}м
|
||||
</text>
|
||||
))}
|
||||
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
|
||||
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
|
||||
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(s.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(s.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(s.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
|
||||
</div>
|
||||
{s.greClients.length > 0 && (
|
||||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(u.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(u.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(u.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(u.txNow)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(c.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(c.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(c.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(c.txNow)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
|
||||
{/* live RX / TX */}
|
||||
<div className="shrink-0 text-right leading-tight">
|
||||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<ArrowDownIcon className="size-3" />{fmtMbps(c.rxNow)}
|
||||
<ArrowDownIcon className="size-3" />{fmtRate(c.rxNow)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
|
||||
<ArrowUpIcon className="size-3" />{fmtMbps(c.txNow)}
|
||||
<ArrowUpIcon className="size-3" />{fmtRate(c.txNow)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ChartLegend() {
|
||||
return (
|
||||
<div className="flex gap-5 mb-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full bg-primary/70" />RX (входящий)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full bg-blue-500/70" />TX (исходящий)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
|
||||
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Получено сегодня</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||
</p>
|
||||
<Sparkline data={rxSeries} width={180} height={28} color="hsl(var(--primary))" filled />
|
||||
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Отправлено сегодня</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||
</p>
|
||||
<Sparkline data={txSeries} width={180} height={28} color="#3b82f6" filled />
|
||||
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||||
|
||||
// ─── detail panels ────────────────────────────────────────────────────────────
|
||||
|
||||
function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) {
|
||||
function ServerDetail({
|
||||
sel, range, setRange, liveRx, liveTx, liveHint,
|
||||
}: {
|
||||
sel: ServerTraffic
|
||||
range: Range
|
||||
setRange: (r: Range) => void
|
||||
liveRx?: number
|
||||
liveTx?: number
|
||||
liveHint?: string
|
||||
}) {
|
||||
const rxNow = liveRx ?? sel.rxNow
|
||||
const txNow = liveTx ?? sel.txNow
|
||||
return (
|
||||
<>
|
||||
<DetailHeader range={range} setRange={setRange}>
|
||||
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
|
||||
<Flag code={sel.country} />{sel.site}
|
||||
</span>
|
||||
</DetailHeader>
|
||||
<ChartLegend />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingDownIcon className="size-3.5 text-purple-500" />} label="Сессий" value={`${sel.sessions}`} />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<KpiStatGrid
|
||||
aria-label="Скорость выбранного сервера"
|
||||
items={[
|
||||
{
|
||||
id: "rx-now",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(rxNow),
|
||||
hint: liveHint,
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx-now",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(txNow),
|
||||
hint: liveHint,
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "rx-peak",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(sel.rxPeak),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "tx-peak",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(sel.txPeak),
|
||||
icon: <TrendingDownIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
{sel.greClients.length > 0 && (
|
||||
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
|
||||
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
|
||||
) : (
|
||||
<>
|
||||
<ChartLegend />
|
||||
<BigChart rx={sel.rxSeries} tx={sel.txSeries} />
|
||||
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||||
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
|
||||
<Flag code={sel.serverCountry} />{sel.serverSite}
|
||||
</span>
|
||||
</DetailHeader>
|
||||
<ChartLegend />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtMbps(sel.txPeak)} />
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
@@ -778,6 +744,12 @@ export default function TrafficPage() {
|
||||
const [detailBusy, setDetailBusy] = useState(false)
|
||||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||||
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
|
||||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||||
backendUrl,
|
||||
serverId: selectedId,
|
||||
iface: selectedIface,
|
||||
})
|
||||
|
||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||
return {
|
||||
@@ -917,6 +889,7 @@ export default function TrafficPage() {
|
||||
}, [sortField, sortDir, q])
|
||||
|
||||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||||
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
||||
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
|
||||
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
|
||||
|
||||
@@ -965,27 +938,39 @@ export default function TrafficPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* ── summary stat cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ icon: <ArrowDownIcon className="size-3.5 text-emerald-500" />, label: "RX сейчас", value: fmtMbps(totalRx) },
|
||||
{ icon: <ArrowUpIcon className="size-3.5 text-blue-500" />, label: "TX сейчас", value: fmtMbps(totalTx) },
|
||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
|
||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
|
||||
].map(({ icon, label, value }) => (
|
||||
<Frame key={label} className="h-full overflow-hidden">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||
<p className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(totalRx),
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(totalTx),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "peak-rx",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(peakRx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "peak-tx",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(peakTx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
|
||||
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
|
||||
{effectiveMode === "servers" && detailServer && (
|
||||
<ServerDetail
|
||||
sel={detailServer}
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
liveRx={liveSample?.rxMbps}
|
||||
liveTx={liveSample?.txMbps}
|
||||
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
|
||||
/>
|
||||
)}
|
||||
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||||
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
||||
</FramePanel>
|
||||
|
||||
Reference in New Issue
Block a user