feat(users): implement user management features and database schema
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 1m57s
Docker images / frontend-image (push) Successful in 4m8s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 44s
Docker images / publish-release (push) Successful in 11s

Added user management functionality, including the creation of app_users and user_interface_bindings tables in the database. Implemented API routes for user data retrieval and permissions handling. Enhanced the traffic monitoring system to include user traffic statistics and interface bindings. Updated relevant components and services to support the new user features, improving overall application functionality and user experience.
This commit is contained in:
Denozordec
2026-09-06 19:20:05 +07:00
parent fe32c9313a
commit b3e50a1f5f
34 changed files with 2883 additions and 1158 deletions
+204 -179
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useMemo, useEffect, useCallback } from "react"
import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
@@ -18,6 +18,14 @@ import { fmtGB, fmtRate } from "@/lib/fmt-rate"
import { useDataSource } from "@/lib/data-source"
import { useTrafficLive } from "@/hooks/use-traffic-live"
import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import {
IFACE_TYPE_LABEL,
INIT_USERS,
ROLE_LABEL,
type InterfaceType,
} from "@/lib/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -37,18 +45,19 @@ function addSeries(a: number[], b: number[]): number[] {
// ─── data model ───────────────────────────────────────────────────────────────
interface GreClientTraffic {
interface BoundIfaceTraffic {
id: string
subUserId: string
bindingId: string
userId: string
userLogin: string
login: string
description: string
userName: string
interfaceName: string
interfaceType: InterfaceType
comment: string
serverId: string
serverName: string
serverSite: string
serverCountry: string
clientIp: string
rxNow: number
txNow: number
rxPeak: number
@@ -75,7 +84,7 @@ interface ServerTraffic {
sessions: number
rxSeries: number[]
txSeries: number[]
greClients: GreClientTraffic[]
boundIfaces: BoundIfaceTraffic[]
}
interface UserTraffic {
@@ -83,7 +92,8 @@ interface UserTraffic {
login: string
displayName: string
role: string
greClients: GreClientTraffic[]
active?: boolean
interfaces: BoundIfaceTraffic[]
rxNow: number
txNow: number
rxPeak: number
@@ -125,129 +135,101 @@ function makeApiFetch(backendUrl: string) {
}
}
// ─── mock GRE client data ─────────────────────────────────────────────────────
function hashSeed(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
return Math.abs(h)
}
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",
},
]
function mockBoundFromUsers(): BoundIfaceTraffic[] {
return INIT_USERS.flatMap((u) =>
u.bindings.map((b) => {
const seed = hashSeed(b.id)
const offline = !u.active || b.interfaceName.includes("retail") || b.interfaceName.includes("warehouse")
const rxNow = offline ? 0 : 12 + (seed % 140)
const txNow = offline ? 0 : 8 + (seed % 110)
return {
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
bindingId: b.id,
userId: u.id,
userLogin: u.login,
userName: u.name,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
comment: b.comment,
serverId: b.serverId,
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
rxNow,
txNow,
rxPeak: rxNow + 40,
txPeak: txNow + 28,
rxTotal: offline ? 0 : +(rxNow / 8).toFixed(1),
txTotal: offline ? 0 : +(txNow / 10).toFixed(1),
rxSeries: offline ? Array(60).fill(0) : buildSeries(seed, rxNow, Math.max(4, rxNow / 4)),
txSeries: offline ? Array(60).fill(0) : buildSeries(seed + 17, txNow, Math.max(3, txNow / 4)),
status: (offline ? "offline" : "online") as "online" | "offline",
}
}),
)
}
// ─── mock server data (with GRE clients attached) ─────────────────────────────
const boundIfaces: BoundIfaceTraffic[] = mockBoundFromUsers()
// ─── mock server data (with bound interfaces 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"),
boundIfaces: boundIfaces.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: [],
boundIfaces: [],
},
{
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: [],
boundIfaces: [],
},
{
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: [],
boundIfaces: [],
},
{
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: [],
boundIfaces: [],
},
{
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: [],
boundIfaces: [],
},
{
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"),
boundIfaces: boundIfaces.filter(c => c.serverId === "srv7"),
},
]
// ─── user traffic (aggregated from GRE clients) ───────────────────────────────
// ─── user traffic (aggregated from bound interfaces) ──────────────────────────
function buildUserTraffic(
id: string, login: string, displayName: string, role: string,
clients: GreClientTraffic[],
clients: BoundIfaceTraffic[],
active = true,
): UserTraffic {
const rxNow = clients.reduce((a, c) => a + c.rxNow, 0)
const txNow = clients.reduce((a, c) => a + c.txNow, 0)
@@ -257,15 +239,19 @@ function buildUserTraffic(
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 }
return { id, login, displayName, role, active, interfaces: 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")),
]
const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
buildUserTraffic(
u.id,
u.login,
u.name,
ROLE_LABEL[u.role],
boundIfaces.filter((c) => c.userId === u.id),
u.active,
),
)
// ─── types ────────────────────────────────────────────────────────────────────
@@ -281,13 +267,13 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
"24h": "24ч",
}
type GroupMode = "servers" | "users" | "gre"
type GroupMode = "servers" | "users" | "ifaces"
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 }) {
function StatChip({ icon, label, value }: { icon: 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>
@@ -347,9 +333,9 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
<ArrowUpIcon className="size-3 text-blue-500" />
<span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
</div>
{s.greClients.length > 0 && (
{s.boundIfaces.length > 0 && (
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<CableIcon className="size-3" />{s.greClients.length}
<CableIcon className="size-3" />{s.boundIfaces.length}
</div>
)}
</div>
@@ -358,7 +344,7 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
}
function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean; onClick: () => void }) {
const hasClients = u.greClients.length > 0
const hasClients = u.interfaces.length > 0
return (
<button onClick={onClick} className={cn(
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
@@ -376,7 +362,7 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
</div>
</div>
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground shrink-0">
<CableIcon className="size-3" />{u.greClients.length}
<CableIcon className="size-3" />{u.interfaces.length}
</div>
</div>
{hasClients ? (
@@ -394,13 +380,13 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
</div>
</>
) : (
<p className="text-[10px] text-muted-foreground">Нет GRE-клиентов</p>
<p className="text-[10px] text-muted-foreground">Нет привязанных интерфейсов</p>
)}
</button>
)
}
function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: boolean; onClick: () => void }) {
function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; 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",
@@ -411,7 +397,7 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
<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-xs font-mono font-medium truncate">{c.interfaceName}</p>
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
</div>
</div>
@@ -440,29 +426,25 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
* 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 }) {
function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; 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>
<span className="text-xs font-mono font-semibold">{c.interfaceName}</span>
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
{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">
<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>
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{c.comment || c.userLogin}</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" />{fmtRate(c.rxNow)}
@@ -471,19 +453,13 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
<ArrowUpIcon className="size-3" />{fmtRate(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" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
)}>
{c.status === "online" ? "online" : "offline"}
</span>
@@ -491,12 +467,12 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
)
}
function GreClientsList({ clients, showServer = false }: { clients: GreClientTraffic[]; showServer?: boolean }) {
function BoundIfacesList({ clients, showServer = false }: { clients: BoundIfaceTraffic[]; showServer?: boolean }) {
if (clients.length === 0)
return <p className="text-xs text-muted-foreground py-1">Нет GRE-клиентов</p>
return <p className="text-xs text-muted-foreground py-1">Нет привязанных интерфейсов</p>
return (
<div className="flex flex-col gap-1.5">
{clients.map(c => <GreClientRow key={c.id} c={c} showServer={showServer} />)}
{clients.map((c) => <IfaceRow key={c.id} c={c} showServer={showServer} />)}
</div>
)
}
@@ -504,7 +480,7 @@ function GreClientsList({ clients, showServer = false }: { clients: GreClientTra
// ─── detail panel helpers ─────────────────────────────────────────────────────
function DetailHeader({ range, setRange, children }: {
range: Range; setRange: (r: Range) => void; children: React.ReactNode
range: Range; setRange: (r: Range) => void; children: ReactNode
}) {
return (
<div className="flex items-start justify-between mb-3 gap-3">
@@ -617,14 +593,14 @@ function ServerDetail({
/>
</div>
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
{sel.greClients.length > 0 && (
{sel.boundIfaces.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>
Привязанные интерфейсы
<span className="text-xs text-muted-foreground font-normal">({sel.boundIfaces.length})</span>
</h3>
<GreClientsList clients={sel.greClients} />
<BoundIfacesList clients={sel.boundIfaces} />
</div>
)}
</>
@@ -632,7 +608,7 @@ function ServerDetail({
}
function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; setRange: (r: Range) => void }) {
const hasClients = sel.greClients.length > 0
const hasClients = sel.interfaces.length > 0
return (
<>
<DetailHeader range={range} setRange={setRange}>
@@ -645,7 +621,7 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
</div>
</DetailHeader>
{!hasClients ? (
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
<OfflinePlaceholder text="Нет привязанных интерфейсов у этого пользователя" />
) : (
<>
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
@@ -653,16 +629,16 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="Интерфейсы" value={`${sel.interfaces.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>
Привязанные интерфейсы
<span className="text-xs text-muted-foreground font-normal">({sel.interfaces.length})</span>
</h3>
<GreClientsList clients={sel.greClients} showServer />
<BoundIfacesList clients={sel.interfaces} showServer />
</div>
</>
)}
@@ -670,15 +646,16 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
)
}
function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Range; setRange: (r: Range) => void }) {
function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; 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>
<h2 className="text-base font-mono font-semibold">{sel.interfaceName}</h2>
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
</div>
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
<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>
@@ -693,15 +670,15 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
<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-xs text-muted-foreground mb-0.5">Сервер</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>
<p className="text-xs text-muted-foreground mb-0.5">Тип</p>
<p className="text-sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">Аккаунт</p>
<p className="text-xs text-muted-foreground mb-0.5">Пользователь</p>
<p className="text-sm font-mono">{sel.userLogin}</p>
</div>
</div>
@@ -711,17 +688,17 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
// ─── page ─────────────────────────────────────────────────────────────────────
const GROUP_MODES: Array<{ mode: GroupMode; icon: React.ReactNode; label: string }> = [
const GROUP_MODES: Array<{ mode: GroupMode; icon: 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" },
{ mode: "ifaces", icon: <CableIcon className="size-3" />, label: "Интерфейсы" },
]
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"] },
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users"] },
]
export default function TrafficPage() {
@@ -743,7 +720,9 @@ export default function TrafficPage() {
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
const [detailBusy, setDetailBusy] = useState(false)
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const effectiveMode: GroupMode = groupMode
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
backendUrl,
@@ -767,7 +746,25 @@ export default function TrafficPage() {
sessions: s.sessions,
rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0),
txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0),
greClients: [],
boundIfaces: [],
}
}
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
...c,
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0),
txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0),
})
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
const ifaces = (u.interfaces ?? []).map(toLiveBound)
return {
...u,
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
interfaces: ifaces,
rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0),
txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0),
}
}
@@ -776,29 +773,48 @@ export default function TrafficPage() {
setLiveBusy(true)
setLiveError(null)
try {
const res = await apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${encodeURIComponent(targetRange)}`)
const mapped: ServerTraffic[] = res.servers.map(toLiveServer)
const q = encodeURIComponent(targetRange)
const [srvRes, usersRes, ifacesRes] = await Promise.all([
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`),
])
const liveIfaces = ifacesRes.interfaces.map(toLiveBound)
const mapped: ServerTraffic[] = srvRes.servers.map((s) => {
const base = toLiveServer(s)
return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) }
})
const mappedUsers = usersRes.users.map(toLiveUser)
setLiveServers(mapped)
setSelectedId((prev) => mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? ""))
setLiveUsers(mappedUsers)
setLiveBoundIfaces(liveIfaces)
setSelectedId((prev) => {
if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "")
if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "")
return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "")
})
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик")
} finally {
setLiveBusy(false)
}
}, [apiFetch, isLive])
}, [apiFetch, isLive, groupMode])
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServers([])
setLiveUsers([])
setLiveBoundIfaces([])
setLiveError(null)
return
}
if (groupMode !== "servers") setGroupMode("servers")
void loadLiveTraffic(range)
}, [isLive, groupMode, range, loadLiveTraffic])
}, [isLive, range, loadLiveTraffic])
const activeServerTraffic = isLive ? liveServers : serverTraffic
const activeUserTraffic = isLive ? liveUsers : userTraffic
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
const visibleIfaces = useMemo(
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
[serverIfaces, hideDisabledIfaces],
@@ -826,18 +842,19 @@ export default function TrafficPage() {
}
setDetailBusy(true)
apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`)
.then((res) => setLiveDetailServer(toLiveServer(res.server)))
.then((res) => setLiveDetailServer({
...toLiveServer(res.server),
boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId),
}))
.catch(() => setLiveDetailServer(null))
.finally(() => setDetailBusy(false))
}, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch])
}, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces])
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
const handleModeChange = (next: GroupMode) => {
setGroupMode(next)
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
else setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
setSortField("rx")
setSortDir("desc")
setSearch("")
@@ -864,39 +881,47 @@ export default function TrafficPage() {
}, [sortField, sortDir, q, activeServerTraffic])
const sortedUsers = useMemo(() => {
return [...userTraffic]
return [...activeUserTraffic]
.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
else if (sortField === "sessions") v = a.interfaces.length - b.interfaces.length
return sortDir === "desc" ? -v : v
})
}, [sortField, sortDir, q])
}, [sortField, sortDir, q, activeUserTraffic])
const sortedGre = useMemo(() => {
return [...greClients]
.filter(c => !q || c.login.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.userLogin.toLowerCase().includes(q))
const sortedIfaces = useMemo(() => {
return [...activeBoundIfaces]
.filter(c => !q
|| c.interfaceName.toLowerCase().includes(q)
|| c.comment.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)
else if (sortField === "name") v = a.interfaceName.localeCompare(b.interfaceName)
return sortDir === "desc" ? -v : v
})
}, [sortField, sortDir, q])
}, [sortField, sortDir, q, activeBoundIfaces])
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces])
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 kpiSource = effectiveMode === "users"
? activeUserTraffic
: effectiveMode === "ifaces"
? activeBoundIfaces
: activeServerTraffic
const totalRx = kpiSource.reduce((a, s) => a + s.rxNow, 0)
const totalTx = kpiSource.reduce((a, s) => a + s.txNow, 0)
const peakRx = kpiSource.reduce((a, s) => Math.max(a, s.rxPeak), 0)
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
@@ -922,7 +947,7 @@ export default function TrafficPage() {
{/* ── 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 }) => (
{GROUP_MODES.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",
@@ -1018,8 +1043,8 @@ export default function TrafficPage() {
{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)} />
{effectiveMode === "ifaces" && sortedIfaces.map(c => (
<IfaceCard key={c.id} c={c} selected={c.id === selectedId} onClick={() => setSelectedId(c.id)} />
))}
</div>
</div>
@@ -1086,8 +1111,8 @@ export default function TrafficPage() {
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
/>
)}
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
{effectiveMode === "users" && selUser && <UserDetail sel={selUser} range={range} setRange={setRange} />}
{effectiveMode === "ifaces" && selIface && <IfaceDetail sel={selIface} range={range} setRange={setRange} />}
</FramePanel>
</Frame>