Files
MikrotikManager/app/(main)/traffic/page.tsx
T
Denozordec 6d8379501c refactor: replace custom API fetch logic with requestJson utility across multiple pages
Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
2026-05-07 13:35:41 +07:00

1100 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
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>
)
}