Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
1100 lines
49 KiB
TypeScript
1100 lines
49 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect, useCallback } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { Flag } from "@/components/flag"
|
||
import {
|
||
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||
} from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function seededRand(seed: number) {
|
||
let s = seed
|
||
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
|
||
}
|
||
|
||
function buildSeries(seed: number, base: number, range: number, n = 60): number[] {
|
||
const rand = seededRand(seed)
|
||
return Array.from({ length: n }, () => Math.max(0, Math.round(base + (rand() - 0.5) * range * 2)))
|
||
}
|
||
|
||
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 {
|
||
id: string
|
||
subUserId: string
|
||
userId: string
|
||
userLogin: string
|
||
login: string
|
||
description: string
|
||
serverId: string
|
||
serverName: string
|
||
serverSite: string
|
||
serverCountry: string
|
||
clientIp: string
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
status: "online" | "offline"
|
||
}
|
||
|
||
interface ServerTraffic {
|
||
id: string
|
||
name: string
|
||
site: string
|
||
country: string
|
||
status: "online" | "offline" | "degraded"
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
sessions: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
greClients: GreClientTraffic[]
|
||
}
|
||
|
||
interface UserTraffic {
|
||
id: string
|
||
login: string
|
||
displayName: string
|
||
role: string
|
||
greClients: GreClientTraffic[]
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
}
|
||
|
||
interface LiveTrafficServer {
|
||
id: string
|
||
name: string
|
||
site: string
|
||
country: string
|
||
status: "online" | "offline" | "degraded"
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
sessions: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
}
|
||
|
||
interface LiveTrafficInterface {
|
||
name: string
|
||
running: boolean
|
||
disabled: boolean
|
||
rxNow: number
|
||
txNow: number
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
// ─── mock GRE client data ─────────────────────────────────────────────────────
|
||
|
||
const greClients: GreClientTraffic[] = [
|
||
// u1 / admin (Иванов)
|
||
{
|
||
id: "su1-srv1", subUserId: "su1", userId: "u1", userLogin: "admin",
|
||
login: "gre-ivanov-01", description: "Иванов — основной тоннель MSK",
|
||
serverId: "srv1", serverName: "mt-msk-core-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.0.10",
|
||
rxNow: 84, txNow: 62, rxPeak: 180, txPeak: 130, rxTotal: 28.4, txTotal: 21.2,
|
||
rxSeries: buildSeries(301, 84, 25), txSeries: buildSeries(401, 62, 20),
|
||
status: "online",
|
||
},
|
||
{
|
||
id: "su1-srv7", subUserId: "su1", userId: "u1", userLogin: "admin",
|
||
login: "gre-ivanov-01", description: "Иванов — основной тоннель LAB",
|
||
serverId: "srv7", serverName: "mt-msk-lab-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.0.10",
|
||
rxNow: 6, txNow: 4, rxPeak: 20, txPeak: 14, rxTotal: 1.8, txTotal: 1.1,
|
||
rxSeries: buildSeries(302, 6, 3), txSeries: buildSeries(402, 4, 2),
|
||
status: "online",
|
||
},
|
||
{
|
||
id: "su2-srv1", subUserId: "su2", userId: "u1", userLogin: "admin",
|
||
login: "gre-ivanov-02", description: "Иванов — резервный MSK",
|
||
serverId: "srv1", serverName: "mt-msk-core-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.0.11",
|
||
rxNow: 42, txNow: 28, rxPeak: 96, txPeak: 64, rxTotal: 14.2, txTotal: 9.6,
|
||
rxSeries: buildSeries(303, 42, 15), txSeries: buildSeries(403, 28, 12),
|
||
status: "online",
|
||
},
|
||
{
|
||
id: "su3-srv7", subUserId: "su3", userId: "u1", userLogin: "admin",
|
||
login: "gre-ivanov-03", description: "Иванов — лабораторный тоннель",
|
||
serverId: "srv7", serverName: "mt-msk-lab-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.0.12",
|
||
rxNow: 4, txNow: 3, rxPeak: 12, txPeak: 8, rxTotal: 1.2, txTotal: 0.8,
|
||
rxSeries: buildSeries(304, 4, 2), txSeries: buildSeries(404, 3, 1),
|
||
status: "online",
|
||
},
|
||
// u2 / operator1 (Петров)
|
||
{
|
||
id: "su4-srv1", subUserId: "su4", userId: "u2", userLogin: "operator1",
|
||
login: "gre-petrov-01", description: "Петров — основной MSK",
|
||
serverId: "srv1", serverName: "mt-msk-core-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.1.10",
|
||
rxNow: 128, txNow: 96, rxPeak: 248, txPeak: 184, rxTotal: 42.8, txTotal: 32.4,
|
||
rxSeries: buildSeries(305, 128, 35), txSeries: buildSeries(405, 96, 28),
|
||
status: "online",
|
||
},
|
||
{
|
||
id: "su4-srv7", subUserId: "su4", userId: "u2", userLogin: "operator1",
|
||
login: "gre-petrov-01", description: "Петров — основной LAB",
|
||
serverId: "srv7", serverName: "mt-msk-lab-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.1.10",
|
||
rxNow: 2, txNow: 1, rxPeak: 8, txPeak: 6, rxTotal: 0.6, txTotal: 0.4,
|
||
rxSeries: buildSeries(306, 2, 1), txSeries: buildSeries(406, 1, 1),
|
||
status: "online",
|
||
},
|
||
// u4 / operator2 (Козлов, неактивный)
|
||
{
|
||
id: "su5-srv1", subUserId: "su5", userId: "u4", userLogin: "operator2",
|
||
login: "gre-kozlov-01", description: "Козлов — MSK",
|
||
serverId: "srv1", serverName: "mt-msk-core-01", serverSite: "MSK", serverCountry: "RU",
|
||
clientIp: "10.200.2.10",
|
||
rxNow: 0, txNow: 0, rxPeak: 44, txPeak: 32, rxTotal: 0, txTotal: 0,
|
||
rxSeries: Array(60).fill(0), txSeries: Array(60).fill(0),
|
||
status: "offline",
|
||
},
|
||
]
|
||
|
||
// ─── mock server data (with GRE clients attached) ─────────────────────────────
|
||
|
||
const serverTraffic: ServerTraffic[] = [
|
||
{
|
||
id: "srv1", name: "mt-msk-core-01", site: "MSK", country: "RU", status: "online",
|
||
rxNow: 342, txNow: 287, rxPeak: 614, txPeak: 521, rxTotal: 124.8, txTotal: 98.2, sessions: 4,
|
||
rxSeries: buildSeries(101, 342, 80), txSeries: buildSeries(201, 287, 70),
|
||
greClients: greClients.filter(c => c.serverId === "srv1"),
|
||
},
|
||
{
|
||
id: "srv2", name: "mt-spb-edge-01", site: "SPB", country: "RU", status: "online",
|
||
rxNow: 218, txNow: 164, rxPeak: 412, txPeak: 310, rxTotal: 78.4, txTotal: 61.1, sessions: 6,
|
||
rxSeries: buildSeries(102, 218, 60), txSeries: buildSeries(202, 164, 50),
|
||
greClients: [],
|
||
},
|
||
{
|
||
id: "srv3", name: "mt-fra-edge-01", site: "FRA", country: "DE", status: "online",
|
||
rxNow: 184, txNow: 141, rxPeak: 388, txPeak: 282, rxTotal: 66.2, txTotal: 51.4, sessions: 3,
|
||
rxSeries: buildSeries(103, 184, 55), txSeries: buildSeries(203, 141, 45),
|
||
greClients: [],
|
||
},
|
||
{
|
||
id: "srv4", name: "mt-ams-edge-01", site: "AMS", country: "NL", status: "degraded",
|
||
rxNow: 88, txNow: 61, rxPeak: 244, txPeak: 188, rxTotal: 32.1, txTotal: 24.8, sessions: 2,
|
||
rxSeries: buildSeries(104, 88, 40), txSeries: buildSeries(204, 61, 30),
|
||
greClients: [],
|
||
},
|
||
{
|
||
id: "srv5", name: "mt-sgp-edge-01", site: "SGP", country: "SG", status: "offline",
|
||
rxNow: 0, txNow: 0, rxPeak: 0, txPeak: 0, rxTotal: 0, txTotal: 0, sessions: 0,
|
||
rxSeries: Array(60).fill(0), txSeries: Array(60).fill(0),
|
||
greClients: [],
|
||
},
|
||
{
|
||
id: "srv6", name: "mt-ams-test-01", site: "AMS", country: "NL", status: "online",
|
||
rxNow: 42, txNow: 28, rxPeak: 118, txPeak: 84, rxTotal: 14.2, txTotal: 9.8, sessions: 1,
|
||
rxSeries: buildSeries(105, 42, 20), txSeries: buildSeries(205, 28, 14),
|
||
greClients: [],
|
||
},
|
||
{
|
||
id: "srv7", name: "mt-msk-lab-01", site: "MSK", country: "RU", status: "online",
|
||
rxNow: 12, txNow: 8, rxPeak: 44, txPeak: 31, rxTotal: 4.2, txTotal: 2.9, sessions: 2,
|
||
rxSeries: buildSeries(106, 12, 6), txSeries: buildSeries(206, 8, 4),
|
||
greClients: greClients.filter(c => c.serverId === "srv7"),
|
||
},
|
||
]
|
||
|
||
// ─── user traffic (aggregated from GRE clients) ───────────────────────────────
|
||
|
||
function buildUserTraffic(
|
||
id: string, login: string, displayName: string, role: string,
|
||
clients: GreClientTraffic[],
|
||
): UserTraffic {
|
||
const rxNow = clients.reduce((a, c) => a + c.rxNow, 0)
|
||
const txNow = clients.reduce((a, c) => a + c.txNow, 0)
|
||
const rxPeak = clients.reduce((a, c) => a + c.rxPeak, 0)
|
||
const txPeak = clients.reduce((a, c) => a + c.txPeak, 0)
|
||
const rxTotal = clients.reduce((a, c) => a + c.rxTotal, 0)
|
||
const txTotal = clients.reduce((a, c) => a + c.txTotal, 0)
|
||
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries), Array(60).fill(0) as number[])
|
||
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries), Array(60).fill(0) as number[])
|
||
return { id, login, displayName, role, greClients: clients, rxNow, txNow, rxPeak, txPeak, rxTotal, txTotal, rxSeries, txSeries }
|
||
}
|
||
|
||
const userTraffic: UserTraffic[] = [
|
||
buildUserTraffic("u1", "admin", "Иванов А.П.", "Администратор", greClients.filter(c => c.userId === "u1")),
|
||
buildUserTraffic("u2", "operator1", "Петров В.С.", "Оператор", greClients.filter(c => c.userId === "u2")),
|
||
buildUserTraffic("u3", "viewer", "Сидоров К.А.", "Наблюдатель", greClients.filter(c => c.userId === "u3")),
|
||
buildUserTraffic("u4", "operator2", "Козлов М.Н.", "Оператор", greClients.filter(c => c.userId === "u4")),
|
||
]
|
||
|
||
// ─── types ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
||
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
|
||
type Range = (typeof TRAFFIC_RANGE_KEYS)[number]
|
||
|
||
const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
||
"5m": "5м",
|
||
"15m": "15м",
|
||
"1h": "1ч",
|
||
"4h": "4ч",
|
||
"24h": "24ч",
|
||
}
|
||
|
||
type GroupMode = "servers" | "users" | "gre"
|
||
type SortField = "rx" | "tx" | "name" | "sessions"
|
||
type SortDir = "asc" | "desc"
|
||
|
||
// ─── shared sub-components ────────────────────────────────────────────────────
|
||
|
||
function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center size-7 rounded-md bg-muted shrink-0">{icon}</div>
|
||
<div className="leading-tight">
|
||
<p className="text-xs text-muted-foreground">{label}</p>
|
||
<p className="text-sm font-semibold tabular-nums">{value}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
|
||
const W = 300, H = height
|
||
const maxVal = Math.max(...rx, ...tx, 1) * 1.1
|
||
const xAt = (i: number) => (i / (rx.length - 1)) * W
|
||
const yAt = (v: number) => H - (v / maxVal) * H
|
||
const area = (arr: number[]) => {
|
||
const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||
return `M 0,${H} L ${pts} L ${W},${H} Z`
|
||
}
|
||
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>
|
||
))}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ─── left-panel entity cards ──────────────────────────────────────────────────
|
||
|
||
function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
s.status === "offline" && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<StatusDot status={s.status} />
|
||
<span className="text-xs font-medium truncate">{s.name}</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||
<Flag code={s.country} />{s.site}
|
||
</span>
|
||
</div>
|
||
<MiniAreaChart rx={s.rxSeries} tx={s.txSeries} />
|
||
<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>
|
||
</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>
|
||
</div>
|
||
{s.greClients.length > 0 && (
|
||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||
<CableIcon className="size-3" />{s.greClients.length}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean; onClick: () => void }) {
|
||
const hasClients = u.greClients.length > 0
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
!hasClients && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<div className="size-5 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||
<UsersIcon className="size-3 text-muted-foreground" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium truncate">{u.login}</p>
|
||
<p className="text-[10px] text-muted-foreground truncate">{u.displayName}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground shrink-0">
|
||
<CableIcon className="size-3" />{u.greClients.length}
|
||
</div>
|
||
</div>
|
||
{hasClients ? (
|
||
<>
|
||
<MiniAreaChart rx={u.rxSeries} tx={u.txSeries} />
|
||
<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>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground">Нет GRE-клиентов</p>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
c.status === "offline" && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<StatusDot status={c.status} />
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-mono font-medium truncate">{c.login}</p>
|
||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||
</div>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||
<Flag code={c.serverCountry} />{c.serverSite}
|
||
</span>
|
||
</div>
|
||
<MiniAreaChart rx={c.rxSeries} tx={c.txSeries} />
|
||
<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>
|
||
</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>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ─── GRE client row (replaces bulky grid tables) ─────────────────────────────
|
||
|
||
/**
|
||
* Compact single-row card for a GRE client.
|
||
* showServer=true adds a server chip (used in user-detail where server is not implied).
|
||
*/
|
||
function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServer?: boolean }) {
|
||
return (
|
||
<div className={cn(
|
||
"flex items-center gap-3 px-3 py-2.5 rounded-lg border bg-card hover:bg-muted/30 transition-colors",
|
||
c.status === "offline" && "opacity-55",
|
||
)}>
|
||
{/* identity */}
|
||
<StatusDot status={c.status} />
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-mono font-semibold">{c.login}</span>
|
||
{showServer && (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground
|
||
bg-muted px-1.5 py-0.5 rounded">
|
||
<Flag code={c.serverCountry} />{c.serverSite}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{c.description}</p>
|
||
</div>
|
||
|
||
{/* 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)}
|
||
</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)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* totals today */}
|
||
<div className="shrink-0 text-right leading-tight text-[10px] text-muted-foreground w-[72px]">
|
||
<p className="tabular-nums">{fmtGB(c.rxTotal)}</p>
|
||
<p className="tabular-nums">{fmtGB(c.txTotal)}</p>
|
||
</div>
|
||
|
||
{/* status dot — badge style */}
|
||
<span className={cn(
|
||
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium tabular-nums",
|
||
c.status === "online"
|
||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||
: "bg-muted text-muted-foreground",
|
||
)}>
|
||
{c.status === "online" ? "online" : "offline"}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GreClientsList({ clients, showServer = false }: { clients: GreClientTraffic[]; showServer?: boolean }) {
|
||
if (clients.length === 0)
|
||
return <p className="text-xs text-muted-foreground py-1">Нет GRE-клиентов</p>
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
{clients.map(c => <GreClientRow key={c.id} c={c} showServer={showServer} />)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── detail panel helpers ─────────────────────────────────────────────────────
|
||
|
||
function DetailHeader({ range, setRange, children }: {
|
||
range: Range; setRange: (r: Range) => void; children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex items-start justify-between mb-3 gap-3">
|
||
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
|
||
<div className="flex gap-1 shrink-0">
|
||
{TRAFFIC_RANGE_KEYS.map((r) => (
|
||
<Button key={r} size="sm" variant={r === range ? "default" : "ghost"}
|
||
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
|
||
{TRAFFIC_RANGE_LABELS[r]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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">
|
||
<div className="text-center">
|
||
<ActivityIcon className="size-10 mx-auto mb-2 opacity-40" />
|
||
<p className="text-sm">{text}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||
rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[]
|
||
}) {
|
||
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-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 />
|
||
</div>
|
||
<div>
|
||
<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 />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── detail panels ────────────────────────────────────────────────────────────
|
||
|
||
function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) {
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<StatusDot status={sel.status} />
|
||
<h2 className="text-base font-semibold">{sel.name}</h2>
|
||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
|
||
<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}`} />
|
||
</div>
|
||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||
{sel.greClients.length > 0 && (
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||
<CableIcon className="size-4 text-muted-foreground" />
|
||
GRE-клиенты
|
||
<span className="text-xs text-muted-foreground font-normal">({sel.greClients.length})</span>
|
||
</h3>
|
||
<GreClientsList clients={sel.greClients} />
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; setRange: (r: Range) => void }) {
|
||
const hasClients = sel.greClients.length > 0
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<div className="size-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||
<UsersIcon className="size-4 text-muted-foreground" />
|
||
</div>
|
||
<div className="leading-tight">
|
||
<h2 className="text-base font-semibold">{sel.login}</h2>
|
||
<p className="text-xs text-muted-foreground">{sel.displayName} · {sel.role}</p>
|
||
</div>
|
||
</DetailHeader>
|
||
{!hasClients ? (
|
||
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
|
||
) : (
|
||
<>
|
||
<ChartLegend />
|
||
<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={<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} />
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||
<CableIcon className="size-4 text-muted-foreground" />
|
||
GRE-клиенты
|
||
<span className="text-xs text-muted-foreground font-normal">({sel.greClients.length})</span>
|
||
</h3>
|
||
<GreClientsList clients={sel.greClients} showServer />
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Range; setRange: (r: Range) => void }) {
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<StatusDot status={sel.status} />
|
||
<div className="leading-tight min-w-0">
|
||
<h2 className="text-base font-mono font-semibold">{sel.login}</h2>
|
||
<p className="text-xs text-muted-foreground truncate">{sel.description}</p>
|
||
</div>
|
||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1 shrink-0">
|
||
<Flag code={sel.serverCountry} />{sel.serverSite}
|
||
</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={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtMbps(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">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Сервер JH</p>
|
||
<p className="text-sm font-mono">{sel.serverName}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">IP клиента</p>
|
||
<p className="text-sm font-mono">{sel.clientIp}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Аккаунт</p>
|
||
<p className="text-sm font-mono">{sel.userLogin}</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||
|
||
const GROUP_MODES: Array<{ mode: GroupMode; icon: React.ReactNode; label: string }> = [
|
||
{ mode: "servers", icon: <ServerIcon className="size-3" />, label: "Серверы" },
|
||
{ mode: "users", icon: <UsersIcon className="size-3" />, label: "Клиенты" },
|
||
{ mode: "gre", icon: <CableIcon className="size-3" />, label: "GRE" },
|
||
]
|
||
|
||
const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [
|
||
{ field: "rx", label: "RX" },
|
||
{ field: "tx", label: "TX" },
|
||
{ field: "name", label: "Имя" },
|
||
{ field: "sessions", label: "Сессий", modesOnly: ["servers"] },
|
||
]
|
||
|
||
export default function TrafficPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
||
const [sortField, setSortField] = useState<SortField>("rx")
|
||
const [sortDir, setSortDir] = useState<SortDir>("desc")
|
||
const [selectedId, setSelectedId] = useState("srv1")
|
||
const [range, setRange] = useState<Range>("1h")
|
||
const [search, setSearch] = useState("")
|
||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||
const [liveBusy, setLiveBusy] = useState(false)
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
|
||
const [selectedIface, setSelectedIface] = useState("__all__")
|
||
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
|
||
const [detailBusy, setDetailBusy] = useState(false)
|
||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
|
||
|
||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||
return {
|
||
id: String(s.id),
|
||
name: s.name,
|
||
site: s.site || "—",
|
||
country: s.country || "UN",
|
||
status: s.status,
|
||
rxNow: s.rxNow,
|
||
txNow: s.txNow,
|
||
rxPeak: s.rxPeak,
|
||
txPeak: s.txPeak,
|
||
rxTotal: s.rxTotal,
|
||
txTotal: s.txTotal,
|
||
sessions: s.sessions,
|
||
rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0),
|
||
txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0),
|
||
greClients: [],
|
||
}
|
||
}
|
||
|
||
const loadLiveTraffic = useCallback(async (targetRange: Range) => {
|
||
if (!isLive) return
|
||
setLiveBusy(true)
|
||
setLiveError(null)
|
||
try {
|
||
const res = await apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${encodeURIComponent(targetRange)}`)
|
||
const mapped: ServerTraffic[] = res.servers.map(toLiveServer)
|
||
setLiveServers(mapped)
|
||
setSelectedId((prev) => mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? ""))
|
||
} catch (e) {
|
||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик")
|
||
} finally {
|
||
setLiveBusy(false)
|
||
}
|
||
}, [apiFetch, isLive])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setLiveServers([])
|
||
setLiveError(null)
|
||
return
|
||
}
|
||
if (groupMode !== "servers") setGroupMode("servers")
|
||
void loadLiveTraffic(range)
|
||
}, [isLive, groupMode, range, loadLiveTraffic])
|
||
|
||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||
const visibleIfaces = useMemo(
|
||
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
|
||
[serverIfaces, hideDisabledIfaces],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "servers" || !selectedId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setServerIfaces([])
|
||
setSelectedIface("__all__")
|
||
setLiveDetailServer(null)
|
||
return
|
||
}
|
||
setSelectedIface("__all__")
|
||
apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`)
|
||
.then((res) => setServerIfaces(res.interfaces))
|
||
.catch(() => setServerIfaces([]))
|
||
}, [isLive, selectedId, effectiveMode, apiFetch])
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "servers" || !selectedId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setLiveDetailServer(null)
|
||
return
|
||
}
|
||
setDetailBusy(true)
|
||
apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`)
|
||
.then((res) => setLiveDetailServer(toLiveServer(res.server)))
|
||
.catch(() => setLiveDetailServer(null))
|
||
.finally(() => setDetailBusy(false))
|
||
}, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch])
|
||
|
||
const handleModeChange = (mode: GroupMode) => {
|
||
if (isLive && mode !== "servers") return
|
||
setGroupMode(mode)
|
||
if (mode === "servers") setSelectedId("srv1")
|
||
else if (mode === "users") setSelectedId("u1")
|
||
else setSelectedId("su1-srv1")
|
||
// reset sort to rx desc when switching
|
||
setSortField("rx")
|
||
setSortDir("desc")
|
||
setSearch("")
|
||
}
|
||
|
||
const toggleSort = (field: SortField) => {
|
||
if (sortField === field) setSortDir(d => d === "desc" ? "asc" : "desc")
|
||
else { setSortField(field); setSortDir("desc") }
|
||
}
|
||
|
||
const q = search.toLowerCase()
|
||
|
||
const sortedServers = useMemo(() => {
|
||
return [...activeServerTraffic]
|
||
.filter(s => !q || s.name.toLowerCase().includes(q) || s.site.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.name.localeCompare(b.name)
|
||
else if (sortField === "sessions") v = a.sessions - b.sessions
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q, activeServerTraffic])
|
||
|
||
const sortedUsers = useMemo(() => {
|
||
return [...userTraffic]
|
||
.filter(u => !q || u.login.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.login.localeCompare(b.login)
|
||
else if (sortField === "sessions") v = a.greClients.length - b.greClients.length
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q])
|
||
|
||
const sortedGre = useMemo(() => {
|
||
return [...greClients]
|
||
.filter(c => !q || c.login.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.userLogin.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.login.localeCompare(b.login)
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q])
|
||
|
||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
|
||
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
|
||
|
||
const totalRx = activeServerTraffic.reduce((a, s) => a + s.rxNow, 0)
|
||
const totalTx = activeServerTraffic.reduce((a, s) => a + s.txNow, 0)
|
||
const peakRx = activeServerTraffic.reduce((a, s) => Math.max(a, s.rxPeak), 0)
|
||
const peakTx = activeServerTraffic.reduce((a, s) => Math.max(a, s.txPeak), 0)
|
||
|
||
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор" }, { label: "Трафик" }]}
|
||
actions={
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLiveTraffic(range) }}
|
||
disabled={isLive && liveBusy}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* ── mode tab bar ── */}
|
||
<div className="border-b bg-background shrink-0">
|
||
<div className="flex items-center px-6">
|
||
{GROUP_MODES.filter(m => !isLive || m.mode === "servers").map(({ mode, icon, label }) => (
|
||
<button key={mode} onClick={() => handleModeChange(mode)}
|
||
className={cn(
|
||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||
effectiveMode === mode
|
||
? "border-primary text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||
)}>
|
||
{icon}{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<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 }) => (
|
||
<Card key={label} className="overflow-hidden">
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">{icon}{label}</p>
|
||
<p className="text-2xl font-semibold tabular-nums tracking-tight">{value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||
|
||
{/* ── left panel ── */}
|
||
<div className="flex flex-col gap-3">
|
||
|
||
{/* search */}
|
||
<div className="relative">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
placeholder="Поиск…"
|
||
className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{/* sort controls */}
|
||
<div className="flex gap-1 flex-wrap">
|
||
<span className="text-[10px] text-muted-foreground self-center mr-0.5">Сортировка:</span>
|
||
{visibleSortFields.map(({ field, label }) => (
|
||
<button key={field} onClick={() => toggleSort(field)}
|
||
className={cn(
|
||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||
sortField === field
|
||
? "border-primary bg-primary/10 text-primary font-medium"
|
||
: "border-border text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{liveError && (
|
||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||
{liveError}
|
||
</div>
|
||
)}
|
||
|
||
{/* entity list */}
|
||
<div className="flex flex-col gap-2">
|
||
{effectiveMode === "servers" && sortedServers.map(s => (
|
||
<ServerCard key={s.id} s={s} selected={s.id === selectedId} onClick={() => setSelectedId(s.id)} />
|
||
))}
|
||
{effectiveMode === "users" && sortedUsers.map(u => (
|
||
<UserCard key={u.id} u={u} selected={u.id === selectedId} onClick={() => setSelectedId(u.id)} />
|
||
))}
|
||
{effectiveMode === "gre" && sortedGre.map(c => (
|
||
<GreCard key={c.id} c={c} selected={c.id === selectedId} onClick={() => setSelectedId(c.id)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── detail panel ── */}
|
||
<Card className="flex flex-col">
|
||
<CardContent className="flex-1 px-5 pb-5 pt-5">
|
||
{isLive && effectiveMode === "servers" && (
|
||
<div className="mb-3 pb-3 border-b">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
|
||
<button
|
||
onClick={() => setHideDisabledIfaces((v) => !v)}
|
||
className={cn(
|
||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium transition-all",
|
||
hideDisabledIfaces
|
||
? "bg-muted text-foreground border-border"
|
||
: "text-muted-foreground border-border hover:text-foreground hover:border-foreground/40",
|
||
)}
|
||
>
|
||
{hideDisabledIfaces ? "Показать отключенные" : "Скрыть отключенные"}
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedIface("__all__")}
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||
selectedIface === "__all__"
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||
)}
|
||
>
|
||
Все
|
||
</button>
|
||
{visibleIfaces.map((iface, index) => {
|
||
const active = selectedIface === iface.name
|
||
return (
|
||
<button
|
||
key={`${iface.name}:${index}`}
|
||
onClick={() => setSelectedIface(iface.name)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||
active
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||
iface.disabled && !active && "opacity-40",
|
||
)}
|
||
>
|
||
<StatusDot status={iface.running && !iface.disabled ? "online" : "offline"} />
|
||
<span className="font-mono">{iface.name}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
{detailBusy && <span className="text-[10px] text-muted-foreground ml-2">Загрузка...</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
|
||
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|