"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" // ─── 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 TrafficSettingsDto { enabled: boolean intervalSec: number retentionDays: number lastCollectedAt: string | null lastDurationMs: number | null lastError: string | null collectorRunning: boolean } interface LiveTrafficInterface { name: string running: boolean disabled: boolean rxNow: number txNow: number } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} const res = await fetch(backendUrl.replace(/\/$/, "") + path, { ...init, headers: { ...headers, ...(init?.headers ?? {}) }, }) if (!res.ok) { let message = res.statusText try { const err = await res.json() as { error?: string } message = err.error ?? message } catch { const text = await res.text().catch(() => "") if (text) message = text } throw new Error(message) } return res.json() as Promise } } // ─── 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 ──────────────────────────────────────────────────────────────────── const RANGES = ["5м", "15м", "1ч", "4ч", "24ч"] as const type Range = typeof RANGES[number] 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 (
{icon}

{label}

{value}

) } 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 ( ) } 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 ( {gridVals.map((p, i) => { const y = pad.t + ih * (1 - p) return ( {fmtMbps(Math.round(maxVal * p))} ) })} {[0, 12, 24, 36, 48, 59].map(i => ( -{60 - i}м ))} ) } // ─── left-panel entity cards ────────────────────────────────────────────────── function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: boolean; onClick: () => void }) { return ( ) } function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean; onClick: () => void }) { const hasClients = u.greClients.length > 0 return ( ) } function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: boolean; onClick: () => void }) { return ( ) } // ─── 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 (
{/* identity */}
{c.login} {showServer && ( {c.serverSite} )}

{c.description}

{/* live RX / TX */}
{fmtMbps(c.rxNow)}
{fmtMbps(c.txNow)}
{/* totals today */}

{fmtGB(c.rxTotal)}

{fmtGB(c.txTotal)}

{/* status dot — badge style */} {c.status === "online" ? "online" : "offline"}
) } function GreClientsList({ clients, showServer = false }: { clients: GreClientTraffic[]; showServer?: boolean }) { if (clients.length === 0) return

Нет GRE-клиентов

return (
{clients.map(c => )}
) } // ─── detail panel helpers ───────────────────────────────────────────────────── function DetailHeader({ range, setRange, children }: { range: Range; setRange: (r: Range) => void; children: React.ReactNode }) { return (
{children}
{RANGES.map(r => ( ))}
) } function ChartLegend() { return (
RX (входящий) TX (исходящий)
) } function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) { return (

{text}

) } function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: { rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[] }) { return (

Получено сегодня

{rxTotal.toFixed(1)} ГБ

Отправлено сегодня

{txTotal.toFixed(1)} ГБ

) } // ─── detail panels ──────────────────────────────────────────────────────────── function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) { return ( <>

{sel.name}

{sel.site}
{sel.status === "offline" ? : }
} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> } label="TX сейчас" value={fmtMbps(sel.txNow)} /> } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> } label="Сессий" value={`${sel.sessions}`} />
{sel.greClients.length > 0 && (

GRE-клиенты ({sel.greClients.length})

)} ) } function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; setRange: (r: Range) => void }) { const hasClients = sel.greClients.length > 0 return ( <>

{sel.login}

{sel.displayName} · {sel.role}

{!hasClients ? ( ) : ( <>
} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> } label="TX сейчас" value={fmtMbps(sel.txNow)} /> } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> } label="GRE-клиентов" value={`${sel.greClients.length}`} />

GRE-клиенты ({sel.greClients.length})

)} ) } function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Range; setRange: (r: Range) => void }) { return ( <>

{sel.login}

{sel.description}

{sel.serverSite}
{sel.status === "offline" ? : }
} label="RX сейчас" value={fmtMbps(sel.rxNow)} /> } label="TX сейчас" value={fmtMbps(sel.txNow)} /> } label="Пик RX" value={fmtMbps(sel.rxPeak)} /> } label="Пик TX" value={fmtMbps(sel.txPeak)} />

Сервер JH

{sel.serverName}

IP клиента

{sel.clientIp}

Аккаунт

{sel.userLogin}

) } // ─── page ───────────────────────────────────────────────────────────────────── const GROUP_MODES: Array<{ mode: GroupMode; icon: React.ReactNode; label: string }> = [ { mode: "servers", icon: , label: "Серверы" }, { mode: "users", icon: , label: "Клиенты" }, { mode: "gre", icon: , 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("servers") const [sortField, setSortField] = useState("rx") const [sortDir, setSortDir] = useState("desc") const [selectedId, setSelectedId] = useState("srv1") const [range, setRange] = useState("1ч") const [search, setSearch] = useState("") const [liveServers, setLiveServers] = useState([]) const [liveBusy, setLiveBusy] = useState(false) const [liveError, setLiveError] = useState(null) const [settingsBusy, setSettingsBusy] = useState(false) const [trafficSettings, setTrafficSettings] = useState(null) const [intervalDraft, setIntervalDraft] = useState("30") const [retentionDraft, setRetentionDraft] = useState("14") const [serverIfaces, setServerIfaces] = useState([]) const [selectedIface, setSelectedIface] = useState("__all__") const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) const [detailBusy, setDetailBusy] = useState(false) const [liveDetailServer, setLiveDetailServer] = useState(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]) const loadTrafficSettings = useCallback(async () => { if (!isLive) return try { const s = await apiFetch("/api/traffic/settings") setTrafficSettings(s) setIntervalDraft(String(s.intervalSec)) setRetentionDraft(String(s.retentionDays)) } catch (e) { setLiveError(e instanceof Error ? e.message : "Не удалось загрузить настройки сбора") } }, [apiFetch, isLive]) useEffect(() => { if (!isLive) { // eslint-disable-next-line react-hooks/set-state-in-effect setLiveServers([]) setLiveError(null) setTrafficSettings(null) return } if (groupMode !== "servers") setGroupMode("servers") void loadLiveTraffic(range) void loadTrafficSettings() }, [isLive, groupMode, range, loadLiveTraffic, loadTrafficSettings]) 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 (
{isLive && ( )} } /> {/* ── mode tab bar ── */}
{GROUP_MODES.filter(m => !isLive || m.mode === "servers").map(({ mode, icon, label }) => ( ))}
{isLive && trafficSettings && (
Сбор статистики
Интервал (сек) setIntervalDraft(e.target.value)} className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2" />
Хранение (дней) setRetentionDraft(e.target.value)} className="h-8 w-24 text-xs bg-muted/50 border border-border rounded-md px-2" />

Последний сбор: {trafficSettings.lastCollectedAt ? new Date(trafficSettings.lastCollectedAt).toLocaleString("ru-RU") : "—"}

Длительность: {trafficSettings.lastDurationMs != null ? `${trafficSettings.lastDurationMs} мс` : "—"}

{trafficSettings.lastError ? `Ошибка: ${trafficSettings.lastError}` : "Ошибок нет"}

)} {/* ── summary stat cards ── */}
{[ { icon: , label: "RX сейчас", value: fmtMbps(totalRx) }, { icon: , label: "TX сейчас", value: fmtMbps(totalTx) }, { icon: , label: "Пик RX", value: fmtMbps(peakRx) }, { icon: , label: "Пик TX", value: fmtMbps(peakTx) }, ].map(({ icon, label, value }) => (

{icon}{label}

{value}

))}
{/* ── left panel ── */}
{/* search */}
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" />
{/* sort controls */}
Сортировка: {visibleSortFields.map(({ field, label }) => ( ))}
{liveError && (
{liveError}
)} {/* entity list */}
{effectiveMode === "servers" && sortedServers.map(s => ( setSelectedId(s.id)} /> ))} {effectiveMode === "users" && sortedUsers.map(u => ( setSelectedId(u.id)} /> ))} {effectiveMode === "gre" && sortedGre.map(c => ( setSelectedId(c.id)} /> ))}
{/* ── detail panel ── */} {isLive && effectiveMode === "servers" && (
Интерфейс: {visibleIfaces.map((iface) => { const active = selectedIface === iface.name return ( ) })} {detailBusy && Загрузка...}
)} {effectiveMode === "servers" && (liveDetailServer ?? selServer) && } {effectiveMode === "users" && } {effectiveMode === "gre" && }
) }