Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec fc161506e7 feat(users): enhance interface type mapping and parsing logic
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m30s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 8s
Updated the mapRosInterfaceType function to support additional interface types, including "gre-tunnel" and improved handling for various input formats. Enhanced the parseRawInterfaces function to accommodate new interface types in the test cases, ensuring accurate parsing and type assignment. Added corresponding test cases to validate the new functionality and ensure robustness in interface type handling.
2026-09-06 19:35:01 +07:00
Denozordec b3e50a1f5f 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.
2026-09-06 19:20:05 +07:00
34 changed files with 2898 additions and 1158 deletions
+7 -799
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle } from "@/components/form-kit"
import { FormToggle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -12,10 +12,6 @@ import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP, type EvoBgpSavePayload, type EvoBgpTestDraft } from "@/lib/evobgp-context"
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
import { Separator } from "@/components/ui/separator"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
@@ -27,119 +23,19 @@ import {
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data"
import {
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon,
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
AlertCircleIcon, EyeIcon, EyeOffIcon,
RefreshCwIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner"
// ─── types ────────────────────────────────────────────────────────────────────
type Role = "admin" | "operator" | "viewer"
type PermLevel = "none" | "read" | "write"
interface SectionPerm { section: string; level: PermLevel }
interface ServerPerm { serverId: string; level: PermLevel }
interface User {
id: string; name: string; login: string; email: string
role: Role; last: string; avatar: string; active: boolean
sections: SectionPerm[]; servers: ServerPerm[]
subUsers: SubUser[]
}
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
// Подчинённый пользователь — учётка для подключения устройства к JH через GRE
interface SubUser {
id: string
login: string
password: string
description: string // "офисный роутер", "склад", и т.п.
jhServerIds: string[] // на каких JH зарегистрирована учётка
clientIp: string // назначенный IP внутри туннеля
active: boolean
lastSeen: string | null
}
// ─── section definitions ──────────────────────────────────────────────────────
const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [
{ group: "Обзор", icon: <LayoutDashboardIcon className="size-3" />, items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
]
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
// ─── default permissions by role ──────────────────────────────────────────────
function defaultSections(role: Role): SectionPerm[] {
return ALL_SECTIONS.map(section => {
let level: PermLevel = "none"
if (role === "admin") level = "write"
else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read"
else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read"
return { section, level }
})
}
function defaultServers(role: Role): ServerPerm[] {
return servers.map(s => ({
serverId: s.id,
level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel,
}))
}
// ─── initial data ──────────────────────────────────────────────────────────────
const INIT_USERS: User[] = [
{
id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io",
role: "admin", last: "сейчас", avatar: "АК", active: true,
sections: defaultSections("admin"), servers: defaultServers("admin"),
subUsers: [
{ id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" },
{ id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" },
{ id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" },
],
},
{
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io",
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" },
],
},
{
id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io",
role: "viewer", last: "вчера", avatar: "МС", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
subUsers: [],
},
{
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io",
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null },
],
},
]
const INIT_API_KEYS: ApiKey[] = [
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
@@ -147,26 +43,7 @@ const INIT_API_KEYS: ApiKey[] = [
{ id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] },
]
const ROLE_LABEL: Record<Role, string> = { admin: "Администратор", operator: "Оператор", viewer: "Наблюдатель" }
const ROLE_COLOR: Record<Role, string> = {
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
viewer: "bg-muted text-muted-foreground border border-border",
}
const PERM_OPTS: { v: PermLevel; label: string }[] = [
{ v: "none", label: "Нет" },
{ v: "read", label: "Просмотр" },
{ v: "write", label: "Управление"},
]
const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-success/10 text-success",
}
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number]
function makeApiFetch(backendUrl: string) {
@@ -175,8 +52,6 @@ function makeApiFetch(backendUrl: string) {
}
}
// ─── small components ─────────────────────────────────────────────────────────
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3.5">
@@ -189,512 +64,6 @@ function SettingRow({ label, description, children }: { label: string; descripti
)
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
>{o.label}</button>
))}
</div>
)
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div className="relative shrink-0">
<div className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold",
"bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}>{avatar}</div>
{!active && (
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-muted-foreground/50 border-2 border-background" />
)}
</div>
)
}
// ─── user sheet ───────────────────────────────────────────────────────────────
interface UserForm {
name: string; login: string; email: string; role: Role; active: boolean
sections: SectionPerm[]; servers: ServerPerm[]
subUsers: SubUser[]
}
function emptyForm(): UserForm {
return { name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [] }
}
function genPassword(): string {
const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%"
return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join("")
}
function genSubLogin(userLogin: string, existing: SubUser[]): string {
const base = `gre-${userLogin.split(".").pop() ?? "client"}`
const n = existing.filter(s => s.login.startsWith(base)).length
return n === 0 ? base : `${base}-${String(n + 1).padStart(2, "0")}`
}
function UserSheet({ open, user, onSave, onClose }: {
open: boolean
user: User | null // null = create
onSave: (f: UserForm) => void
onClose: () => void
}) {
const isCreate = user === null
const [tab, setTab] = useState<"profile" | "sections" | "servers" | "subusers">("profile")
const [form, setForm] = useState<UserForm>(() =>
user ? {
name: user.name, login: user.login, email: user.email, role: user.role,
active: user.active,
sections: user.sections.map(s => ({ ...s })),
servers: user.servers.map(s => ({ ...s })),
subUsers: user.subUsers.map(s => ({ ...s })),
} : emptyForm()
)
// sub-user add form state
const [addSubOpen, setAddSubOpen] = useState(false)
const [newSubLogin, setNewSubLogin] = useState("")
const [newSubPwd, setNewSubPwd] = useState("")
const [newSubDesc, setNewSubDesc] = useState("")
const [newSubJhs, setNewSubJhs] = useState<string[]>([])
const [newSubIp, setNewSubIp] = useState("")
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
const [errors, setErrors] = useState<Partial<Record<keyof UserForm, string>>>({})
const setField = <K extends keyof UserForm>(k: K, v: UserForm[K]) => {
setForm(f => ({ ...f, [k]: v }))
if (errors[k]) setErrors(e => ({ ...e, [k]: undefined }))
}
// When role changes → reset permissions to defaults
const setRole = (role: Role) => {
setForm(f => ({ ...f, role, sections: defaultSections(role), servers: defaultServers(role) }))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm(f => ({
...f,
sections: f.sections.map(s => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm(f => ({
...f,
servers: f.servers.map(s => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm(f => ({ ...f, sections: f.sections.map(s => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm(f => ({ ...f, servers: f.servers.map(s => ({ ...s, level })) }))
}
const validate = () => {
const e: Partial<Record<keyof UserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!form.login.trim()) e.login = "Обязательное поле"
if (!form.email.trim()) e.email = "Обязательное поле"
setErrors(e)
return !Object.keys(e).length
}
const handleSave = () => { if (validate()) onSave(form) }
const isAdmin = form.role === "admin"
const jhServers = servers.filter(s => s.type === "jump-host" && s.enabled)
const addSubUser = () => {
if (!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0) return
const sub: SubUser = {
id: `su${Date.now()}`,
login: newSubLogin.trim(),
password: newSubPwd,
description: newSubDesc.trim(),
jhServerIds: [...newSubJhs],
clientIp: newSubIp.trim(),
active: true,
lastSeen: null,
}
setForm(f => ({ ...f, subUsers: [...f.subUsers, sub] }))
setAddSubOpen(false)
setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([])
}
const toggleNewSubJh = (id: string) =>
setNewSubJhs(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
const removeSubUser = (id: string) =>
setForm(f => ({ ...f, subUsers: f.subUsers.filter(s => s.id !== id) }))
const toggleSubUser = (id: string) =>
setForm(f => ({ ...f, subUsers: f.subUsers.map(s => s.id === id ? { ...s, active: !s.active } : s) }))
const toggleReveal = (id: string) =>
setRevealedIds(prev => {
const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next
})
const TABS = [
{ id: "profile", label: "Профиль" },
{ id: "sections", label: "Разделы" },
{ id: "servers", label: "Серверы" },
{ id: "subusers", label: "GRE-клиенты", badge: form.subUsers.length || undefined },
] as const
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="sm:max-w-[520px] p-0 flex flex-col" showCloseButton={false}>
{/* header */}
<SheetHeader className="px-5 pt-5 pb-0 border-b shrink-0">
<div className="flex items-start justify-between gap-2 pb-4">
<div className="flex items-center gap-3">
{!isCreate && user && (
<AvatarCircle avatar={user.avatar} active={form.active} />
)}
<div>
<SheetTitle className="text-base leading-tight">
{isCreate ? "Новый пользователь" : form.name || "—"}
</SheetTitle>
<SheetDescription className="text-xs mt-0.5">
{isCreate ? "Заполните данные и настройте доступ" : `@${form.login}`}
</SheetDescription>
</div>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5">
<XIcon className="size-4" />
</Button>
</div>
{/* tab bar */}
<div className="flex gap-0 -mb-px">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id as typeof tab)}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5",
tab === t.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{t.label}
{"badge" in t && t.badge !== undefined && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full tabular-nums",
tab === t.id
? "bg-foreground/10"
: "bg-muted text-muted-foreground",
)}>{t.badge}</span>
)}
</button>
))}
</div>
</SheetHeader>
{/* body */}
<div className="flex-1 overflow-y-auto">
{/* ── Profile tab ── */}
{tab === "profile" && (
<div className="px-5 py-5 flex flex-col gap-4">
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</FormField>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</FormField>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</FormField>
<Separator />
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
className={cn(
"px-3 py-1.5 rounded-md border text-xs font-medium transition-all",
form.role === r
? r === "admin" ? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30 ring-1 ring-violet-500/30"
: r === "operator" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30 ring-1 ring-sky-500/30"
: "bg-muted text-foreground border-border ring-1 ring-border"
: "border-border text-muted-foreground hover:bg-muted",
)}>
{ROLE_LABEL[r]}
</button>
))}
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
{form.role === "admin"
? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав"
: form.role === "operator"
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</FormField>
<Separator />
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</FormField>
</div>
)}
{/* ── Sections tab ── */}
{tab === "sections" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUPS.map(group => (
<div key={group.group}>
{/* group header */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{group.icon}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
{/* section rows */}
<div className="divide-y divide-border/60">
{group.items.map(section => {
const perm = form.sections.find(s => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between px-4 py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={v => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</div>
)}
{/* ── Servers tab ── */}
{tab === "servers" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
<div className="divide-y divide-border/60">
{servers.map(srv => {
const perm = form.servers.find(s => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<StatusDot status={srv.status} />
{srv.country && <Flag code={srv.country} size={14} />}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={v => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
)}
{/* ── GRE-клиенты tab ── */}
{tab === "subusers" && (
<div className="flex flex-col">
{/* hint */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<CableIcon className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-[11px] text-muted-foreground">
Учётные записи для подключения устройств к JH через GRE
</p>
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
<div className="border-t bg-muted/20 px-4 py-4 flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground">Новый GRE-клиент</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Логин</label>
<Input className="h-8 text-xs font-mono" placeholder="gre-office-msk"
value={newSubLogin} onChange={e => setNewSubLogin(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Описание</label>
<Input className="h-8 text-xs" placeholder="Офис MSK"
value={newSubDesc} onChange={e => setNewSubDesc(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Пароль</label>
<div className="flex gap-1.5">
<Input className="h-8 text-xs font-mono flex-1" placeholder="••••••••"
value={newSubPwd} onChange={e => setNewSubPwd(e.target.value)} />
<Button variant="outline" size="sm" className="h-8 px-2 shrink-0"
onClick={() => setNewSubPwd(genPassword())} title="Сгенерировать пароль">
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">
JH-серверы
<span className="ml-1 text-muted-foreground/50">(можно выбрать несколько)</span>
</label>
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5">
{jhServers.map(s => {
const checked = newSubJhs.includes(s.id)
return (
<label key={s.id}
className="flex items-center gap-2 py-0.5 cursor-pointer hover:text-foreground transition-colors">
<input type="checkbox" checked={checked}
onChange={() => toggleNewSubJh(s.id)}
className="rounded border-input accent-primary" />
<Flag code={s.country} size={12} />
<span className="font-mono text-xs">{s.name}</span>
<span className="text-[10px] text-muted-foreground">{s.site}</span>
</label>
)
})}
</div>
{newSubJhs.length === 0 && (
<p className="text-[10px] text-destructive">Выберите хотя бы один JH-сервер</p>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">IP-клиента</label>
<Input className="h-8 text-xs font-mono" placeholder="10.210.0.18"
value={newSubIp} onChange={e => setNewSubIp(e.target.value)} />
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => { setAddSubOpen(false); setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) }}>
Отмена
</Button>
<Button size="sm" className="flex-1"
disabled={!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0}
onClick={addSubUser}>
<PlusIcon className="size-3.5" />Добавить
</Button>
</div>
</div>
) : (
<button
onClick={() => {
setNewSubLogin(genSubLogin(form.login, form.subUsers))
setNewSubPwd(genPassword())
setNewSubJhs(jhServers.length > 0 ? [jhServers[0].id] : [])
setAddSubOpen(true)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />Добавить GRE-клиента
</button>
)}
</div>
)}
</div>
{/* footer */}
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1">
<CheckIcon className="size-4" />
{isCreate ? "Создать" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
open,
filename,
@@ -737,41 +106,6 @@ function DatabaseRestoreConfirm({
)
}
function DeleteConfirm({
open,
user,
onConfirm,
onCancel,
}: {
open: boolean
user: User
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{user.name} · @{user.login}. Это действие нельзя отменить.
Пользователь потеряет доступ немедленно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
@@ -824,12 +158,6 @@ export default function SettingsPage() {
const [notifBgp, setNotifBgp] = useState(true)
const [notifBackup, setNotifBackup] = useState(false)
// users
const [users, setUsers] = useState<User[]>(INIT_USERS)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<User | null>(null) // null = create mode
const [deleteTarget, setDeleteTarget]= useState<User | null>(null)
// api keys
const [apiKeys, setApiKeys] = useState<ApiKey[]>(INIT_API_KEYS)
@@ -851,22 +179,6 @@ export default function SettingsPage() {
setTimeout(() => setSaved(false), 2000)
}, [])
const handleUserSave = (form: UserForm) => {
if (!editUser) {
const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase()
setUsers(prev => [...prev, {
id: "u" + Date.now(), ...form,
last: "только что", avatar: initials || "??",
}])
} else {
setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form } : u))
}
setSheetOpen(false)
}
// total sub-users count for summary
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
@@ -1409,94 +721,6 @@ export default function SettingsPage() {
</div>
)
// ── Пользователи ──
if (section === "Пользователи") return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{users.length} пользователей · {users.filter(u => u.active).length} активных
{totalSubUsers > 0 && ` · ${totalSubUsers} GRE-клиентов`}
</p>
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
</div>
<Frame dense className="w-full">
<FramePanel className="overflow-hidden p-0">
{/* table header */}
<div className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2 border-b bg-muted/30 text-[11px] font-medium text-muted-foreground">
<span>Пользователь</span>
<span>Роль</span>
<span>Последний вход</span>
<span>Статус</span>
<span />
</div>
<div className="divide-y divide-border/60">
{users.map(u => {
return (
<div key={u.id} className="grid grid-cols-[1fr_120px_100px_80px_auto] items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
{/* user */}
<div className="flex items-center gap-2.5 min-w-0">
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-[11px] font-mono text-muted-foreground">@{u.login}</p>
{u.subUsers.length > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-muted-foreground/60">
<CableIcon className="size-2.5" />{u.subUsers.length}
</span>
)}
</div>
</div>
</div>
{/* role */}
<span className={cn("text-[11px] font-medium px-2 py-0.5 rounded-full w-fit", ROLE_COLOR[u.role])}>
{ROLE_LABEL[u.role]}
</span>
{/* last */}
<span className="text-xs text-muted-foreground">{u.last}</span>
{/* status */}
<span className={cn("text-[11px] font-medium", u.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{u.active ? "Активен" : "Заблокирован"}
</span>
{/* actions */}
<div className="flex items-center gap-0.5 justify-end">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => { setEditUser(u); setSheetOpen(true) }} title="Редактировать">
<PencilIcon className="size-3.5" />
</Button>
{u.id !== "u1" && (
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(u)} title="Удалить">
<TrashIcon className="size-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
</FramePanel>
</Frame>
{/* access summary */}
<DataPageCard>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<UserIcon className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">Сводка прав доступа</span>
</div>
<SettingsAccessSummaryDataGrid
users={users}
servers={servers}
allSectionsCount={ALL_SECTIONS.length}
/>
</DataPageCard>
</div>
)
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="flex flex-col gap-4">
@@ -1655,15 +879,6 @@ export default function SettingsPage() {
</div>
</div>
{/* user sheet */}
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
onSave={handleUserSave}
onClose={() => setSheetOpen(false)}
/>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
@@ -1677,14 +892,6 @@ export default function SettingsPage() {
}}
/>
)}
{deleteTarget && (
<DeleteConfirm
open={!!deleteTarget}
user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
@@ -1701,3 +908,4 @@ export default function SettingsPage() {
</div>
)
}
+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>
+294
View File
@@ -0,0 +1,294 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { UserSheet } from "@/components/users/user-sheet"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { servers as mockServers } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import {
ALL_SECTIONS,
INIT_USERS,
userInitials,
type AppUser,
type AppUserForm,
type UserServerOption,
} from "@/lib/users"
import { listServers } from "@/shared/api/servers"
import {
createAppUser,
createUserBinding,
deleteAppUser,
deleteUserBinding,
listAppUsers,
updateAppUser,
} from "@/shared/api/users"
import { ApiClientError } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
CableIcon,
PlusIcon,
TrashIcon,
UserCheckIcon,
UsersIcon,
} from "lucide-react"
function toServerOptionsFromMock(): UserServerOption[] {
return mockServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
}))
}
export default function UsersPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [editUser, setEditUser] = useState<AppUser | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
const loadLive = useCallback(async () => {
setLoading(true)
try {
const [list, srvs] = await Promise.all([
listAppUsers(backendUrl),
listServers(backendUrl),
])
setUsers(list)
setServerOptions(srvs.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
site: s.site,
country: s.country,
status: s.status,
})))
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
} finally {
setLoading(false)
}
}, [backendUrl])
useEffect(() => {
if (!isLive) {
setUsers(INIT_USERS)
setServerOptions(toServerOptionsFromMock())
return
}
void loadLive()
}, [isLive, loadLive])
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
const activeCount = users.filter((u) => u.active).length
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
const nextKeys = new Set(next.map((b) => `${b.serverId}::${b.interfaceName}`))
const prevKeys = new Map(prev.map((b) => [`${b.serverId}::${b.interfaceName}`, b] as const))
for (const b of prev) {
if (!nextKeys.has(`${b.serverId}::${b.interfaceName}`)) {
await deleteUserBinding(backendUrl, userId, b.id)
}
}
for (const b of next) {
if (!prevKeys.has(`${b.serverId}::${b.interfaceName}`)) {
await createUserBinding(backendUrl, userId, {
serverId: Number(b.serverId),
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
comment: b.comment,
})
}
}
}
const handleSave = async (form: AppUserForm) => {
if (!isLive) {
if (!editUser) {
const id = `u${Date.now()}`
const created: AppUser = {
id,
...form,
last: "только что",
avatar: userInitials(form.name),
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
}
setUsers((prev) => [...prev, created])
} else {
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
return
}
setSaving(true)
try {
if (!editUser) {
const created = await createAppUser(backendUrl, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(created.id, form.bindings, [])
await loadLive()
} else {
await updateAppUser(backendUrl, editUser.id, {
name: form.name,
login: form.login,
email: form.email,
role: form.role,
active: form.active,
avatar: userInitials(form.name),
sections: form.sections,
servers: form.servers,
})
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
await loadLive()
}
setSheetOpen(false)
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
} catch (err) {
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
toast.error(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) return
if (!isLive) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
setDeleteTarget(null)
toast.success("Пользователь удалён")
return
}
try {
await deleteAppUser(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await loadLive()
toast.success("Пользователь удалён")
} catch (err) {
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
}
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
actions={
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
<PlusIcon className="size-4" />Пригласить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
<KpiStatGrid
aria-label="Сводка пользователей"
items={[
{
id: "all",
label: "Пользователи",
value: users.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "active",
label: "Активные",
value: activeCount,
icon: <UserCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ifaces",
label: "Привязанные ifaces",
value: boundCount,
icon: <CableIcon className="size-4" />,
iconClassName: "text-info",
},
]}
/>
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
<DataPageCard>
<UsersDataGrid
users={users}
serversCount={serverOptions.length}
allSectionsCount={ALL_SECTIONS.length}
isLoading={loading}
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
onDelete={setDeleteTarget}
/>
</DataPageCard>
</div>
<UserSheet
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
open={sheetOpen}
user={editUser}
users={users}
servers={serverOptions}
isLive={isLive}
backendUrl={backendUrl}
saving={saving}
onSave={(f) => { void handleSave(f) }}
onClose={() => setSheetOpen(false)}
/>
{deleteTarget && (
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
+2 -1
View File
@@ -13,7 +13,8 @@
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts"
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+31
View File
@@ -511,6 +511,37 @@ CREATE TABLE IF NOT EXISTS alert_engine_cursor (
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name)
);
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
ON user_interface_bindings(user_id);
`)
// Lightweight schema evolution for existing databases without migrations
+39
View File
@@ -4,6 +4,7 @@ import {
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core"
// ── servers ────────────────────────────────────────────────────────────────────
@@ -540,6 +541,42 @@ export const internetPathSettings = sqliteTable("internet_path_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── app users (local catalog, not portal JWT) ────────────────────────────────
export const appUsers = sqliteTable("app_users", {
id: text("id").primaryKey(),
name: text("name").notNull().default(""),
login: text("login").notNull().unique(),
email: text("email").notNull().default(""),
role: text("role", { enum: ["admin", "operator", "viewer"] }).notNull().default("viewer"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
avatar: text("avatar").notNull().default(""),
lastSeen: text("last_seen"),
sectionsJson: text("sections_json").notNull().default("[]"),
serversJson: text("servers_json").notNull().default("[]"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const userInterfaceBindings = sqliteTable("user_interface_bindings", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => appUsers.id, { onDelete: "cascade" }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
interfaceType: text("interface_type", { enum: ["ether", "gre", "wg", "other"] })
.notNull()
.default("other"),
comment: text("comment").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}, (t) => [
uniqueIndex("idx_user_iface_bind_server_name").on(t.serverId, t.interfaceName),
])
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
@@ -582,3 +619,5 @@ export type AlertDestinationRow = typeof alertDestinations.$inferSelect
export type AlertHistoryRow = typeof alertHistory.$inferSelect
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
export type AppUserRow = typeof appUsers.$inferSelect
export type UserInterfaceBindingRow = typeof userInterfaceBindings.$inferSelect
+2
View File
@@ -25,6 +25,7 @@ import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
export async function buildApp(opts?: {
@@ -107,6 +108,7 @@ export async function buildApp(opts?: {
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
await app.register(firewallRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
refreshScheduler()
+9 -2
View File
@@ -38,8 +38,15 @@ assert.equal(
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/firewall/rules"),
"mm:network:write",
permissionForRequest("GET", "/api/users"),
"mm:users:read",
)
assert.equal(
permissionForRequest("POST", "/api/users"),
"mm:users:write",
)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
console.log("permissions.test.ts: ok")
+14 -2
View File
@@ -17,6 +17,9 @@ export function hasPermission(
required: string,
): boolean {
if (granted.includes(required)) return true
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
@@ -49,8 +52,17 @@ const RULES: Rule[] = [
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/users"),
permission: "mm:users:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
@@ -0,0 +1,62 @@
import assert from "node:assert/strict"
import Database from "better-sqlite3"
const sqlite = new Database(":memory:")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE servers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '127.0.0.1'
);
CREATE TABLE app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name)
);
`)
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
`).run(),
/UNIQUE/i,
"один интерфейс на сервере — один пользователь",
)
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
assert.equal(leftover.n, 0, "каскад: привязки удаляются вместе с пользователем")
console.log("users bindings unique+cascade tests ok")
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
assert.equal(mapRosInterfaceType("ether"), "ether")
assert.equal(mapRosInterfaceType("ethernet"), "ether")
assert.equal(mapRosInterfaceType("GRE"), "gre")
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
assert.equal(mapRosInterfaceType("wg"), "wg")
assert.equal(mapRosInterfaceType("wireguard"), "wg")
assert.equal(mapRosInterfaceType("vlan"), "other")
assert.equal(mapRosInterfaceType(""), "other")
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
const parsed = parseRawInterfaces(JSON.stringify([
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
{ name: "wg-msk", type: "wg", running: true, disabled: false },
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
{ name: "", type: "ether" },
]))
assert.equal(parsed.length, 4)
assert.equal(parsed[0]?.type, "ether")
assert.equal(parsed[0]?.running, true)
assert.equal(parsed[1]?.type, "gre")
assert.equal(parsed[1]?.running, false)
assert.equal(parsed[2]?.type, "wg")
assert.equal(parsed[3]?.type, "gre")
assert.equal(parseRawInterfaces("not-json").length, 0)
assert.equal(parseRawInterfaces(null).length, 0)
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
assert.equal(isUniqueConstraintError({ message: "other" }), false)
console.log("users iface-type tests ok")
+60
View File
@@ -0,0 +1,60 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
if (t === "wg" || t === "wireguard") return "wg"
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
export interface ParsedRosIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
}
function asBool(raw: unknown): boolean {
if (typeof raw === "boolean") return raw
const s = String(raw ?? "").trim().toLowerCase()
return s === "true" || s === "yes" || s === "1"
}
export function parseRawInterfaces(json: string | null | undefined): ParsedRosIface[] {
if (!json) return []
try {
const parsed = JSON.parse(json) as unknown
const arr = Array.isArray(parsed) ? parsed : []
const out: ParsedRosIface[] = []
for (const item of arr) {
if (!item || typeof item !== "object") continue
const rec = item as Record<string, unknown>
const name = String(rec.name ?? "").trim()
if (!name) continue
out.push({
name,
type: mapRosInterfaceType(String(rec.type ?? ""), name),
running: asBool(rec.running),
disabled: asBool(rec.disabled),
})
}
return out
} catch {
return []
}
}
export function isUniqueConstraintError(err: unknown): boolean {
if (!err || typeof err !== "object") return false
const rec = err as { code?: unknown; message?: unknown }
const code = String(rec.code ?? "")
const msg = String(rec.message ?? "")
return code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
}
@@ -0,0 +1,75 @@
import { and, eq } from "drizzle-orm"
import { db } from "../../../db/index.js"
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export function listUserRows(): AppUserRow[] {
return db.select().from(appUsers).all()
}
export function getUserRowById(id: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
}
export function getUserRowByLogin(login: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
}
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
const [inserted] = db.insert(appUsers).values(values).returning().all()
return inserted
}
export function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): AppUserRow {
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
return updated
}
export function deleteUserRowById(id: string): void {
db.delete(appUsers).where(eq(appUsers.id, id)).run()
}
export function listBindingRows(): BindingRow[] {
return db.select().from(userInterfaceBindings).all()
}
export function listBindingRowsByUser(userId: string): BindingRow[] {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
}
export function getBindingRowById(id: string): BindingRow | undefined {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
}
export function getBindingByServerIface(
serverId: number,
interfaceName: string,
): BindingRow | undefined {
return db
.select()
.from(userInterfaceBindings)
.where(and(
eq(userInterfaceBindings.serverId, serverId),
eq(userInterfaceBindings.interfaceName, interfaceName),
))
.limit(1)
.all()[0]
}
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
return inserted
}
export function deleteBindingRowById(id: string): void {
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
}
export function countUserRows(): number {
return db.select().from(appUsers).all().length
}
@@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto"
import { desc, eq } from "drizzle-orm"
import type {
AppUserCreate,
AppUserRead,
AppUserUpdate,
CatalogInterface,
InterfaceType,
SectionPerm,
ServerPerm,
UserBinding,
UserBindingCreate,
} from "@mmapp/contracts/users"
import { db } from "../../../db/index.js"
import { servers, trafficSamples } from "../../../db/schema.js"
import {
createBindingRow,
createUserRow,
deleteBindingRowById,
deleteUserRowById,
getBindingByServerIface,
getBindingRowById,
getUserRowById,
getUserRowByLogin,
listBindingRows,
listBindingRowsByUser,
listUserRows,
updateUserRowById,
type AppUserRow,
type BindingRow,
} from "../repository/users-repository.js"
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
import {
isUniqueConstraintError,
mapRosInterfaceType,
parseRawInterfaces,
} from "../iface-type.js"
export class UsersServiceError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message)
this.name = "UsersServiceError"
}
}
function parseJsonArray<T>(raw: string, fallback: T[]): T[] {
try {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? (parsed as T[]) : fallback
} catch {
return fallback
}
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
function serverMeta(serverId: number): { name: string; site: string; country: string } {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
country: row?.country || "UN",
}
}
function toBindingDto(row: BindingRow): UserBinding {
const meta = serverMeta(row.serverId)
return {
id: row.id,
userId: row.userId,
serverId: row.serverId,
serverName: meta.name,
serverSite: meta.site,
serverCountry: meta.country,
interfaceName: row.interfaceName,
interfaceType: row.interfaceType,
comment: row.comment,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
return {
id: row.id,
name: row.name,
login: row.login,
email: row.email,
role: row.role,
active: Boolean(row.active),
avatar: row.avatar,
lastSeen: row.lastSeen ?? null,
sections: parseJsonArray<SectionPerm>(row.sectionsJson, []),
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
bindings: bindings.map(toBindingDto),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function listUsers(): AppUserRead[] {
const users = listUserRows()
const allBindings = listBindingRows()
const byUser = new Map<string, BindingRow[]>()
for (const b of allBindings) {
const arr = byUser.get(b.userId) ?? []
arr.push(b)
byUser.set(b.userId, arr)
}
return users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
}
export function getUserById(id: string): AppUserRead | undefined {
const row = getUserRowById(id)
if (!row) return undefined
return toUserDto(row, listBindingRowsByUser(id))
}
export function createUser(input: AppUserCreate): AppUserRead {
const login = input.login.trim()
if (getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = createUserRow({
id: randomUUID(),
name: input.name.trim(),
login,
email: input.email.trim(),
role: input.role ?? "viewer",
active: input.active ?? true,
avatar: (input.avatar ?? "").trim() || initials(input.name),
lastSeen: input.lastSeen ?? null,
sectionsJson: JSON.stringify(input.sections ?? []),
serversJson: JSON.stringify(input.servers ?? []),
createdAt: now,
updatedAt: now,
})
return toUserDto(row, [])
}
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = getUserRowByLogin(input.login.trim())
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
}
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
if (input.name != null) patch.name = input.name.trim()
if (input.login != null) patch.login = input.login.trim()
if (input.email != null) patch.email = input.email.trim()
if (input.role != null) patch.role = input.role
if (input.active != null) patch.active = input.active
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
if (input.sections != null) patch.sectionsJson = JSON.stringify(input.sections)
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
const updated = updateUserRowById(id, patch)
return toUserDto(updated, listBindingRowsByUser(id))
}
export function deleteUser(id: string): void {
const existing = getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
deleteUserRowById(id)
}
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
const user = getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const taken = getBindingByServerIface(input.serverId, ifaceName)
if (taken) {
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
}
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
const now = new Date().toISOString()
try {
const row = createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
interfaceName: ifaceName,
interfaceType: type,
comment: (input.comment ?? "").trim(),
createdAt: now,
updatedAt: now,
})
return toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError("Интерфейс уже привязан к другому пользователю", 409)
}
throw err
}
}
export function removeBinding(userId: string, bindingId: string): void {
const row = getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
deleteBindingRowById(bindingId)
}
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
const snap = getLatestSnapshot(serverId)
const parsed = parseRawInterfaces(snap?.rawInterfaces)
const found = parsed.find((i) => i.name === ifaceName)
return found?.type ?? "other"
}
export function listInterfaceCatalog(serverId: number): CatalogInterface[] {
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const snap = getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const last = db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
if (last) {
const rows = db
.select({
interfaceName: trafficSamples.interfaceName,
running: trafficSamples.running,
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName))
const seen = new Set<string>()
ifaces = []
for (const r of rows) {
if (seen.has(r.interfaceName)) continue
seen.add(r.interfaceName)
ifaces.push({
name: r.interfaceName,
type: mapRosInterfaceType("", r.interfaceName),
running: Boolean(r.running),
disabled: Boolean(r.disabled),
})
}
}
}
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
return ifaces.map((iface) => {
const bind = bindings.find((b) => b.interfaceName === iface.name)
const owner = bind ? usersById.get(bind.userId) : undefined
return {
name: iface.name,
type: iface.type,
running: iface.running,
disabled: iface.disabled,
boundUserId: bind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
}
}).sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }
+4
View File
@@ -9,6 +9,7 @@ import {
uptimeProbes,
uptimeSpeedProbes,
} from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/sidebar-counts", async (_req, reply) => {
@@ -20,6 +21,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutesTotal,
certificatesTotal,
wireguardTotal,
usersTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
@@ -28,6 +30,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0),
Promise.resolve(listUsers().length),
])
return reply.send({
@@ -39,6 +42,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
users: usersTotal,
})
})
}
+34
View File
@@ -22,6 +22,10 @@ import {
parseMonitorTraffic,
rateBpsFromDelta,
} from "../services/traffic-rate.js"
import {
buildBoundInterfaceTraffic,
buildUserTrafficList,
} from "../services/traffic-users.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -427,6 +431,36 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
)
return reply.send({ server: data })
})
app.get("/traffic/users", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ users: buildUserTrafficList(rangeStartMs, rangeEndMs) })
})
app.get("/traffic/users/:id", async (req, reply) => {
const p = req.params as { id?: string }
const q = req.query as { range?: string }
const id = String(p.id ?? "")
if (!id) return reply.status(400).send({ error: "id is required" })
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
const users = buildUserTrafficList(rangeStartMs, rangeEndMs)
const user = users.find((u) => u.id === id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.get("/traffic/bound-interfaces", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const rangeEndMs = Date.now()
const rangeStartMs = rangeEndMs - minutes * 60_000
return reply.send({ interfaces: buildBoundInterfaceTraffic(rangeStartMs, rangeEndMs) })
})
}
export default trafficRoutes
+117
View File
@@ -0,0 +1,117 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
appUserCreateSchema,
appUserIdParamSchema,
appUserUpdateSchema,
bindingIdParamSchema,
interfaceCatalogQuerySchema,
userBindingCreateSchema,
type AppUserCreate,
type AppUserUpdate,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import {
addBinding,
createUser,
deleteUser,
getUserById,
listInterfaceCatalog,
listUsers,
removeBinding,
updateUser,
UsersServiceError,
} from "../modules/users/service/users-service.js"
function sendServiceError(reply: { status: (c: number) => { send: (b: unknown) => unknown } }, err: unknown) {
if (err instanceof UsersServiceError) {
return reply.status(err.status).send({ error: err.message })
}
throw err
}
const usersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/users", async (_req, reply) => {
return reply.send({ users: listUsers() })
})
app.get("/users/interface-catalog", {
schema: { querystring: interfaceCatalogQuerySchema },
}, async (req, reply) => {
const q = req.query as { serverId: number }
try {
return reply.send({ interfaces: listInterfaceCatalog(q.serverId) })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.get("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
const user = getUserById(id)
if (!user) return reply.status(404).send({ error: "Пользователь не найден" })
return reply.send({ user })
})
app.post("/users", {
schema: { body: appUserCreateSchema },
}, async (req, reply) => {
try {
const user = createUser(req.body as AppUserCreate)
return reply.status(201).send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.patch("/users/:id", {
schema: { params: appUserIdParamSchema, body: appUserUpdateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const user = updateUser(id, req.body as AppUserUpdate)
return reply.send({ user })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id", {
schema: { params: appUserIdParamSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
deleteUser(id)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
app.post("/users/:id/bindings", {
schema: { params: appUserIdParamSchema, body: userBindingCreateSchema },
}, async (req, reply) => {
const { id } = req.params as { id: string }
try {
const binding = addBinding(id, req.body as UserBindingCreate)
return reply.status(201).send({ binding })
} catch (err) {
return sendServiceError(reply, err)
}
})
app.delete("/users/:id/bindings/:bindingId", {
schema: { params: bindingIdParamSchema },
}, async (req, reply) => {
const { id, bindingId } = req.params as { id: string; bindingId: string }
try {
removeBinding(id, bindingId)
return reply.status(204).send()
} catch (err) {
return sendServiceError(reply, err)
}
})
}
export default usersRoutes
+24
View File
@@ -4,6 +4,7 @@ import {
bucketAvg,
buildTrafficFromSamples,
isLoopbackName,
mergeBuiltTraffic,
parseMonitorTraffic,
rateBpsFromDelta,
shouldIncludeIface,
@@ -73,4 +74,27 @@ const onceOnly = parseMonitorTraffic(
)
assert.equal(onceOnly.rxMbps, 1)
const boundOnly = buildTrafficFromSamples(samples, start, end, ["ether1", "lo"])
assert.ok(boundOnly.rxPeak > 0, "bound list includes ether1")
assert.ok(boundOnly.rxPeak > built.rxPeak, "lo included when listed")
const merged = mergeBuiltTraffic([
buildTrafficFromSamples(samples, start, end, "ether1"),
buildTrafficFromSamples(samples, start, end, "ether1"),
])
assert.equal(merged.rxNow, built.rxNow * 2)
const wgSamples: TrafficSampleLike[] = [
{ interfaceName: "wg-msk", sampledAt: t0, rxBytes: 2_000_000, txBytes: 1_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-msk", sampledAt: t1, rxBytes: 2_000_000 + 1_875_000, txBytes: 1_000_000 + 937_500, rxBps: 0, txBps: 0, running: true, disabled: false },
{ interfaceName: "wg-msk", sampledAt: t2, rxBytes: 50, txBytes: 25, rxBps: 0, txBps: 0, running: true, disabled: false },
]
const userAgg = mergeBuiltTraffic([
buildTrafficFromSamples(samples, start, end, "ether1"),
buildTrafficFromSamples(wgSamples, start, end, "wg-msk"),
])
const listed = buildTrafficFromSamples([...samples, ...wgSamples], start, end, ["ether1", "wg-msk"])
assert.equal(userAgg.rxNow, listed.rxNow, "сумма привязанных ifaces = фильтр по списку имён")
assert.ok(userAgg.rxNow > built.rxNow, "агрегация пользователя больше одного iface")
console.log("traffic-rate tests ok")
+51 -5
View File
@@ -98,7 +98,7 @@ export function buildTrafficFromSamples(
rows: TrafficSampleLike[],
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string,
onlyInterface?: string | readonly string[],
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
@@ -120,6 +120,10 @@ export function buildTrafficFromSamples(
byIface.set(r.interfaceName, arr)
}
const allowList = Array.isArray(onlyInterface)
? onlyInterface
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
const rxPoints: Array<{ t: number; v: number }> = []
const txPoints: Array<{ t: number; v: number }> = []
const byTs = new Map<number, { rx: number; tx: number }>()
@@ -129,8 +133,8 @@ export function buildTrafficFromSamples(
let sessions = 0
for (const [name, arr] of byIface) {
if (onlyInterface) {
if (name !== onlyInterface) continue
if (allowList) {
if (!allowList.includes(name)) continue
} else if (isLoopbackName(name)) {
continue
}
@@ -138,7 +142,7 @@ export function buildTrafficFromSamples(
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
if (!last) continue
if (!onlyInterface && (!last.running || last.disabled)) continue
if (!allowList && (!last.running || last.disabled)) continue
if (last.running && !last.disabled) sessions += 1
@@ -154,7 +158,7 @@ export function buildTrafficFromSamples(
const prev = sorted[i - 1]
const cur = sorted[i]
if (!prev || !cur) continue
if (!onlyInterface && (!cur.running || cur.disabled)) continue
if (!allowList && (!cur.running || cur.disabled)) continue
const t0 = parseIsoMs(prev.sampledAt)
const t1 = parseIsoMs(cur.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
@@ -194,6 +198,48 @@ export function buildTrafficFromSamples(
}
}
export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
if (parts.length === 0) return empty
const acc = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
for (const p of parts) {
acc.rxNow += p.rxNow
acc.txNow += p.txNow
acc.rxPeak += p.rxPeak
acc.txPeak += p.txPeak
acc.rxTotalGiB += p.rxTotalGiB
acc.txTotalGiB += p.txTotalGiB
acc.sessions += p.sessions
for (let i = 0; i < SERIES_POINTS; i++) {
acc.rxSeries[i] += p.rxSeries[i] ?? 0
acc.txSeries[i] += p.txSeries[i] ?? 0
}
}
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
return acc
}
export function parseMonitorTraffic(
raw: unknown,
opts?: { onlyInterface?: string },
+141
View File
@@ -0,0 +1,141 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
import { readServerSamplesInRange } from "./traffic-collector.js"
import {
buildTrafficFromSamples,
mergeBuiltTraffic,
type BuiltTrafficSeries,
} from "./traffic-rate.js"
export interface BoundIfaceTrafficDto {
id: string
bindingId: string
userId: string
userLogin: string
userName: string
interfaceName: string
interfaceType: string
comment: string
serverId: string
serverName: string
serverSite: string
serverCountry: string
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
status: "online" | "offline"
}
export interface UserTrafficDto {
id: string
login: string
displayName: string
role: string
active: boolean
interfaces: BoundIfaceTrafficDto[]
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
}
function seriesFromBuilt(built: BuiltTrafficSeries) {
return {
rxNow: built.rxNow,
txNow: built.txNow,
rxPeak: built.rxPeak,
txPeak: built.txPeak,
rxTotal: built.rxTotalGiB,
txTotal: built.txTotalGiB,
rxSeries: built.rxSeries,
txSeries: built.txSeries,
}
}
function serverStatus(serverId: number): "online" | "offline" {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!row?.enabled) return "offline"
return "online"
}
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = listUsers()
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
return users.map((user) => {
const parts: BuiltTrafficSeries[] = []
const interfaces: BoundIfaceTrafficDto[] = []
const byServer = new Map<number, string[]>()
for (const b of user.bindings) {
const arr = byServer.get(b.serverId) ?? []
arr.push(b.interfaceName)
byServer.set(b.serverId, arr)
}
for (const [serverId, names] of byServer) {
let rows = sampleCache.get(serverId)
if (!rows) {
rows = readServerSamplesInRange(serverId, sinceIso)
sampleCache.set(serverId, rows)
}
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, names)
parts.push(built)
}
for (const b of user.bindings) {
let rows = sampleCache.get(b.serverId)
if (!rows) {
rows = readServerSamplesInRange(b.serverId, sinceIso)
sampleCache.set(b.serverId, rows)
}
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName)
const last = [...rows.filter((r) => r.interfaceName === b.interfaceName)]
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt))
.at(-1)
const running = Boolean(last?.running) && !last?.disabled
interfaces.push({
id: `${b.userId}:${b.serverId}:${b.interfaceName}`,
bindingId: b.id,
userId: user.id,
userLogin: user.login,
userName: user.name,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
comment: b.comment,
serverId: String(b.serverId),
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
...seriesFromBuilt(built),
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
})
}
const merged = mergeBuiltTraffic(parts)
return {
id: user.id,
login: user.login,
displayName: user.name,
role: user.role,
active: user.active,
interfaces,
...seriesFromBuilt(merged),
}
})
}
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
}
+4 -1
View File
@@ -35,6 +35,7 @@ import {
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
UsersIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
@@ -70,6 +71,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Пользователи", url: "/users", icon: <UsersIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
@@ -102,7 +104,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number; users?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl, prefsHydrated } = useDataSource()
@@ -161,6 +163,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/users") return formatSidebarBadgeCount(liveCounts.users ?? 0)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
+2 -1
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -36,6 +36,7 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: <UsersIcon />, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
@@ -1,167 +0,0 @@
"use client"
import { useMemo } from "react"
import type { Server } from "@/lib/data"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
type PermLevel = "none" | "read" | "write"
type Role = "admin" | "operator" | "viewer"
interface SectionPerm {
section: string
level: PermLevel
}
interface ServerPerm {
serverId: string
level: PermLevel
}
export interface AccessSummaryUser {
id: string
name: string
avatar: string
active: boolean
role: Role
sections: SectionPerm[]
servers: ServerPerm[]
}
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{avatar}
</div>
)
}
interface SettingsAccessSummaryDataGridProps {
users: AccessSummaryUser[]
servers: Server[]
allSectionsCount: number
}
function SettingsAccessSummaryDataGrid({
users,
servers,
allSectionsCount,
}: SettingsAccessSummaryDataGridProps) {
const columns = useMemo<CompactDataGridColumn<AccessSummaryUser>[]>(
() => [
{
id: "name",
header: "Пользователь",
accessorKey: "name",
cell: (u) => (
<div className="flex items-center gap-2">
<AvatarCircle avatar={u.avatar} active={u.active} />
<span className="text-sm font-medium">{u.name}</span>
</div>
),
},
{
id: "sections",
header: "Разделы",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
const readSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "read").map((s) => s.section)
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({allSectionsCount})
</span>
) : (
<span>{readSections.length + writeSections.length} из {allSectionsCount}</span>
)}
</span>
)
},
},
{
id: "servers",
header: "Серверы",
enableSorting: false,
cell: (u) => {
const accessServers =
u.role === "admin"
? servers
: servers.filter((s) => u.servers.find((p) => p.serverId === s.id && p.level !== "none"))
return (
<span className="text-xs text-muted-foreground">
{u.role === "admin" ? (
<span className="text-violet-600 dark:text-violet-400 font-medium">
Все ({servers.length})
</span>
) : (
<span>
{accessServers.length} из {servers.length}
</span>
)}
</span>
)
},
},
{
id: "write",
header: "Права записи",
enableSorting: false,
cell: (u) => {
const writeSections =
u.role === "admin"
? []
: u.sections.filter((s) => s.level === "write").map((s) => s.section)
return (
<div className="flex flex-wrap gap-1">
{u.role === "admin" ? (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">
Полный доступ
</span>
) : writeSections.length === 0 ? (
<span className="text-[10px] text-muted-foreground">Только просмотр</span>
) : (
<>
{writeSections.slice(0, 3).map((s) => (
<span
key={s}
className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
>
{s}
</span>
))}
{writeSections.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
)}
</>
)}
</div>
)
},
},
],
[allSectionsCount, servers],
)
return (
<CompactDataGrid
data={users}
columns={columns}
emptyTitle="Нет пользователей"
/>
)
}
export { SettingsAccessSummaryDataGrid, type SettingsAccessSummaryDataGridProps }
+244
View File
@@ -0,0 +1,244 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
import { EmptyState } from "@/components/empty-state"
import { ALL_SECTIONS, ROLE_LABEL, type AppUser } from "@/lib/users"
import {
CableIcon,
ChevronDownIcon,
ChevronRightIcon,
PencilIcon,
TrashIcon,
UsersIcon,
} from "lucide-react"
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
return (
<div
className={cn(
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
!active && "opacity-50",
)}
>
{avatar}
</div>
)
}
interface UsersDataGridProps {
users: AppUser[]
serversCount: number
allSectionsCount?: number
isLoading?: boolean
onEdit: (user: AppUser) => void
onDelete: (user: AppUser) => void
}
function UsersDataGrid({
users,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
isLoading,
onEdit,
onDelete,
}: UsersDataGridProps) {
const columns = useMemo<ColumnDef<AppUser>[]>(
() => [
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
),
cell: ({ row }) => {
const u = row.original
const expanded = row.getIsExpanded()
return (
<div className="flex items-start gap-2 min-w-0">
{expanded ? (
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
)}
<AvatarCircle avatar={u.avatar} active={u.active} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.name}</p>
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
</div>
</div>
)
},
meta: {
headerTitle: "Пользователь",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: AppUser) => (
<UsersExpandedDetail
user={row}
serversCount={serversCount}
allSectionsCount={allSectionsCount}
/>
),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => <DataGridSortHeader column={column} title="Роль" />,
cell: ({ row }) => (
<Badge
variant={row.original.role === "admin" ? "primary-light" : row.original.role === "operator" ? "info-light" : "outline"}
size="sm"
>
{ROLE_LABEL[row.original.role]}
</Badge>
),
meta: {
headerTitle: "Роль",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "last",
accessorKey: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="Последний вход" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.last}</span>
),
meta: {
headerTitle: "Последний вход",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "ifaces",
accessorFn: (u) => u.bindings.length,
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CableIcon className="size-3.5" />
{row.original.bindings.length}
</span>
),
meta: {
headerTitle: "Интерфейсы",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "status",
accessorKey: "active",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => (
<span
className={cn(
"text-[11px] font-medium",
row.original.active ? "text-success" : "text-muted-foreground",
)}
>
{row.original.active ? "Активен" : "Заблокирован"}
</span>
),
meta: {
headerTitle: "Статус",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const u = row.original
return (
<div
className="flex items-center justify-end gap-0.5"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEdit(u)}
title="Редактировать"
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(u)}
title="Удалить"
>
<TrashIcon className="size-3.5" />
</Button>
</div>
)
},
meta: {
headerTitle: "Действия",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[allSectionsCount, onDelete, onEdit, serversCount],
)
const table = useReactTable({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
getRowCanExpand: () => true,
})
if (!isLoading && users.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пользователей"
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
className="border-0 py-12"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={users.length}
isLoading={isLoading}
loadingMode="skeleton"
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
/>
)
}
export { UsersDataGrid, type UsersDataGridProps }
@@ -0,0 +1,126 @@
"use client"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import {
ALL_SECTIONS,
groupBindingsByServer,
IFACE_TYPE_LABEL,
summarizeUserAccess,
type AppUser,
type InterfaceType,
} from "@/lib/users"
import { CableIcon, NetworkIcon, ShieldIcon } from "lucide-react"
const TYPE_VARIANT: Record<InterfaceType, "outline" | "info-light" | "success-light" | "secondary"> = {
ether: "outline",
gre: "info-light",
wg: "success-light",
other: "secondary",
}
const TYPE_ICON: Record<InterfaceType, { icon: typeof CableIcon; className: string }> = {
ether: { icon: CableIcon, className: "text-muted-foreground" },
gre: { icon: NetworkIcon, className: "text-info" },
wg: { icon: ShieldIcon, className: "text-success" },
other: { icon: CableIcon, className: "text-muted-foreground" },
}
interface UsersExpandedDetailProps {
user: AppUser
serversCount: number
allSectionsCount?: number
}
function UsersExpandedDetail({
user,
serversCount,
allSectionsCount = ALL_SECTIONS.length,
}: UsersExpandedDetailProps) {
const access = summarizeUserAccess(user, serversCount, allSectionsCount)
const groups = groupBindingsByServer(user.bindings)
return (
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">
Разделы:{" "}
<span className="font-mono text-foreground">
{access.sectionsGranted}/{access.sectionsTotal}
</span>
</span>
<span className="text-muted-foreground">
Серверы:{" "}
<span className="font-mono text-foreground">
{access.serversGranted}/{access.serversTotal}
</span>
</span>
{access.writeKind === "full" ? (
<Badge variant="success-light" size="sm">Полный доступ</Badge>
) : access.writeKind === "none" ? (
<Badge variant="outline" size="sm">Только просмотр</Badge>
) : (
<span className="inline-flex flex-wrap items-center gap-1">
{access.writeSections.map((section) => (
<Badge key={section} variant="info-light" size="sm">{section}</Badge>
))}
</span>
)}
</div>
{groups.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
) : (
groups.map((g) => {
const items = g.types.flatMap((tg) => tg.items)
return (
<div key={g.serverId}>
<div className="flex items-center gap-3 mb-3">
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
{g.serverName}
</p>
<Flag code={g.serverCountry} size={12} />
<span className="text-[11px] font-mono text-muted-foreground">{g.serverSite}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
{items.map((b) => {
const meta = TYPE_ICON[b.interfaceType]
const Icon = meta.icon
return (
<div
key={b.id}
className="flex items-start gap-2 rounded-md border px-3 py-2.5"
>
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", meta.className)}>
<Icon />
</IconTile>
<div className="min-w-0">
<p className="text-xs font-mono font-medium leading-tight truncate">
{b.interfaceName}
</p>
<div className="flex flex-wrap items-center gap-1 mt-0.5">
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">
{IFACE_TYPE_LABEL[b.interfaceType]}
</Badge>
{b.comment ? (
<span className="text-[10px] text-muted-foreground leading-tight truncate">
{b.comment}
</span>
) : null}
</div>
</div>
</div>
)
})}
</div>
</div>
)
})
)}
</div>
)
}
export { UsersExpandedDetail, TYPE_VARIANT, type UsersExpandedDetailProps }
+519
View File
@@ -0,0 +1,519 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,
} from "@/components/ui/sheet"
import {
Stepper,
StepperContent,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from "@/components/reui/stepper"
import { Badge } from "@/components/reui/badge"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { cn } from "@/lib/utils"
import { listInterfaceCatalog } from "@/shared/api/users"
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
import {
catalogForServer,
defaultSections,
defaultServers,
IFACE_TYPE_LABEL,
PERM_COLOR,
PERM_OPTS,
ROLE_LABEL,
SECTION_GROUP_DEFS,
type AppUser,
type AppUserForm,
type CatalogIface,
type InterfaceBinding,
type InterfaceType,
type PermLevel,
type Role,
type UserServerOption,
} from "@/lib/users"
import {
LayoutDashboardIcon, EyeIcon, PlusIcon, ServerIcon, ShieldIcon,
TrashIcon, WrenchIcon,
} from "lucide-react"
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
"Обзор": <LayoutDashboardIcon className="size-3" />,
"Данные": <EyeIcon className="size-3" />,
"Управление": <WrenchIcon className="size-3" />,
"Инструменты": <ShieldIcon className="size-3" />,
"Система": <ServerIcon className="size-3" />,
}
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
return (
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
{PERM_OPTS.map((o, i) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
className={cn(
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
>
{o.label}
</button>
))}
</div>
)
}
function emptyForm(serverList: UserServerOption[]): AppUserForm {
return {
name: "", login: "", email: "", role: "viewer", active: true,
sections: defaultSections("viewer"),
servers: defaultServers("viewer", serverList),
bindings: [],
}
}
function toForm(user: AppUser): AppUserForm {
return {
name: user.name, login: user.login, email: user.email, role: user.role, active: user.active,
sections: user.sections.map((s) => ({ ...s })),
servers: user.servers.map((s) => ({ ...s })),
bindings: user.bindings.map((b) => ({ ...b })),
}
}
function isValidEmail(value: string) {
const email = value.trim()
return email.length > 0 && email.includes("@")
}
interface UserSheetProps {
open: boolean
user: AppUser | null
users: AppUser[]
servers: UserServerOption[]
isLive: boolean
backendUrl: string
saving?: boolean
onSave: (form: AppUserForm) => void
onClose: () => void
}
function UserSheet({
open, user, users, servers, isLive, backendUrl, saving, onSave, onClose,
}: UserSheetProps) {
const isCreate = user === null
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
const [errors, setErrors] = useState<Partial<Record<keyof AppUserForm, string>>>({})
const [catalogServerId, setCatalogServerId] = useState(servers[0]?.id ?? "")
const [catalog, setCatalog] = useState<CatalogIface[]>([])
const [selectedNames, setSelectedNames] = useState<string[]>([])
const [newComment, setNewComment] = useState("")
useEffect(() => {
if (!open) return
setForm(user ? toForm(user) : emptyForm(servers))
setSheetStep(1)
setErrors({})
setCatalogServerId(servers[0]?.id ?? "")
setSelectedNames([])
setNewComment("")
}, [open, user, servers])
useEffect(() => {
if (!open || !catalogServerId) {
setCatalog([])
return
}
if (!isLive) {
const occupancy = users.map((u) =>
u.id === user?.id ? { ...u, bindings: form.bindings } : u,
)
const withDraft = user
? occupancy
: [...occupancy, { id: "__draft__", name: "", login: "", email: "", role: "viewer" as const, last: "", avatar: "", active: true, sections: [], servers: [], bindings: form.bindings }]
setCatalog(catalogForServer(catalogServerId, withDraft))
return
}
let cancelled = false
void listInterfaceCatalog(backendUrl, catalogServerId)
.then((ifaces) => { if (!cancelled) setCatalog(ifaces) })
.catch(() => { if (!cancelled) setCatalog([]) })
return () => { cancelled = true }
}, [open, catalogServerId, isLive, backendUrl, users, user, form.bindings])
const setField = <K extends keyof AppUserForm>(k: K, v: AppUserForm[K]) => {
setForm((f) => ({ ...f, [k]: v }))
if (errors[k]) setErrors((e) => ({ ...e, [k]: undefined }))
}
const setRole = (role: Role) => {
setForm((f) => ({
...f,
role,
sections: defaultSections(role),
servers: servers.map((s) => ({ serverId: s.id, level: role === "admin" ? "write" : "read" })),
}))
}
const setSectionPerm = (section: string, level: PermLevel) => {
setForm((f) => ({
...f,
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
}))
}
const setServerPerm = (serverId: string, level: PermLevel) => {
setForm((f) => ({
...f,
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
}))
}
const setAllSections = (level: PermLevel) => {
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
}
const setAllServers = (level: PermLevel) => {
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
}
const submit = () => {
const email = form.email.trim()
const e: Partial<Record<keyof AppUserForm, string>> = {}
if (!form.name.trim()) e.name = "Обязательное поле"
if (!isValidEmail(email)) e.email = email ? "Укажите корректный email" : "Обязательное поле"
setErrors(e)
if (Object.keys(e).length) {
setSheetStep(1)
return
}
onSave({ ...form, email, login: email })
}
const isAdmin = form.role === "admin"
const catalogSrv = servers.find((s) => s.id === catalogServerId)
const addSelectedBindings = () => {
if (!catalogSrv || selectedNames.length === 0) return
const existing = new Set(form.bindings.map((b) => `${b.serverId}::${b.interfaceName}`))
const next: InterfaceBinding[] = [...form.bindings]
for (const name of selectedNames) {
const key = `${catalogSrv.id}::${name}`
if (existing.has(key)) continue
const iface = catalog.find((c) => c.name === name)
if (!iface) continue
if (iface.boundUserId && iface.boundUserId !== user?.id) continue
next.push({
id: `pending-${catalogSrv.id}-${name}`,
userId: user?.id ?? "",
serverId: catalogSrv.id,
serverName: catalogSrv.name,
serverSite: catalogSrv.site,
serverCountry: catalogSrv.country,
interfaceName: name,
interfaceType: iface.type,
comment: newComment.trim(),
})
}
setForm((f) => ({ ...f, bindings: next }))
setSelectedNames([])
setNewComment("")
}
const removeBinding = (id: string) => {
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
}
const toggleName = (name: string) => {
setSelectedNames((prev) => prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name])
}
const alreadyBoundHere = useMemo(
() => new Set(form.bindings.filter((b) => b.serverId === catalogServerId).map((b) => b.interfaceName)),
[form.bindings, catalogServerId],
)
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
<SheetDescription>
{isCreate ? "Профиль, права и привязка интерфейсов" : "Права доступа и привязка интерфейсов"}
</SheetDescription>
</SheetHeader>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Профиль</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">Разделы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">Серверы</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Интерфейсы</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Полное имя" required error={errors.name}>
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
</FormField>
<FormField label="Email" required error={errors.email}>
<Input
value={form.email}
onChange={(e) => setField("email", e.target.value)}
placeholder="i.ivanov@company.io"
type="email"
/>
</FormField>
<FormField label="Роль" required>
<SegmentedControl
value={form.role}
onChange={setRole}
options={[
{ value: "viewer", label: ROLE_LABEL.viewer },
{ value: "operator", label: ROLE_LABEL.operator },
{ value: "admin", label: ROLE_LABEL.admin },
]}
/>
</FormField>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Активна</span>
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col">
<div className="flex items-center gap-2 px-0 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUP_DEFS.map((group) => (
<div key={group.group}>
<div className="flex items-center gap-2 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{SECTION_GROUP_ICONS[group.group]}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
<div className="divide-y divide-border/60">
{group.items.map((section) => {
const perm = form.sections.find((s) => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</StepperContent>
<StepperContent value={3} className="flex flex-col">
<div className="flex items-center gap-2 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map((o) => (
<button
key={o.v}
type="button"
onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}
>
{o.label}
</button>
))}
</div>
<div className="divide-y divide-border/60">
{servers.map((srv) => {
const perm = form.servers.find((s) => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
{srv.status ? <StatusDot status={srv.status} /> : null}
{srv.country ? <Flag code={srv.country} size={14} /> : null}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</StepperContent>
<StepperContent value={4} className="flex flex-col">
<p className="text-[11px] text-muted-foreground py-2.5 border-b">
Привязка интерфейсов сервера. Один интерфейс один пользователь.
</p>
<div className="py-3 flex flex-col gap-3 border-b">
<label className="text-[10px] text-muted-foreground">Сервер</label>
<select
className="h-8 rounded-md border bg-background px-2 text-xs font-mono"
value={catalogServerId}
onChange={(e) => { setCatalogServerId(e.target.value); setSelectedNames([]) }}
>
{servers.map((s) => (
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
))}
</select>
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5 max-h-48 overflow-y-auto">
{catalog.length === 0 && (
<p className="text-[11px] text-muted-foreground py-1">Нет интерфейсов в каталоге</p>
)}
{catalog.map((iface) => {
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
const mine = alreadyBoundHere.has(iface.name)
const disabled = taken || mine
return (
<label
key={iface.name}
className={cn(
"flex items-center gap-2 py-0.5 text-xs",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
)}
>
<input
type="checkbox"
disabled={disabled}
checked={selectedNames.includes(iface.name)}
onChange={() => toggleName(iface.name)}
className="rounded border-input accent-primary"
/>
<span className="font-mono">{iface.name}</span>
<Badge variant={TYPE_VARIANT[iface.type as InterfaceType]} size="sm">
{IFACE_TYPE_LABEL[iface.type as InterfaceType]}
</Badge>
{taken && (
<span className="text-[10px] text-muted-foreground ml-auto">{iface.boundUserLogin}</span>
)}
{mine && !taken && (
<span className="text-[10px] text-muted-foreground ml-auto">уже привязан</span>
)}
</label>
)
})}
</div>
<Input
className="h-8 text-xs"
placeholder="Комментарий (необязательно)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button size="sm" disabled={selectedNames.length === 0} onClick={addSelectedBindings}>
<PlusIcon className="size-3.5" />Привязать
</Button>
</div>
<div className="flex flex-col gap-1.5 py-3">
{form.bindings.length === 0 && (
<p className="text-xs text-muted-foreground">Нет привязанных интерфейсов</p>
)}
{form.bindings.map((b) => (
<div key={b.id} className="flex items-center gap-2 py-1">
<Flag code={b.serverCountry} size={12} />
<span className="font-mono text-xs truncate">{b.interfaceName}</span>
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
<span className="text-[10px] text-muted-foreground truncate">{b.serverName}</span>
<Button
size="sm"
variant="ghost"
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
onClick={() => removeBinding(b.id)}
>
<TrashIcon className="size-3.5" />
</Button>
</div>
))}
</div>
</StepperContent>
</StepperPanel>
</Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={saving} onClick={submit}>
{isCreate ? "Создать" : "Сохранить"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { UserSheet, type UserSheetProps }
+5
View File
@@ -197,6 +197,9 @@ export function hasPermission(
required: string,
): boolean {
if (granted.includes(required)) return true
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
@@ -241,6 +244,7 @@ export function permissionForPath(pathname: string): string | null {
) {
return "mm:network:read"
}
if (pathname.startsWith("/users")) return "mm:users:read"
if (pathname.startsWith("/settings")) return "mm:settings:admin"
return "mm:dashboard:read"
}
@@ -249,6 +253,7 @@ export function firstAllowedPath(): string {
const candidates = [
"/dashboard",
"/servers",
"/users",
"/filters",
"/uptime",
"/traffic",
+3
View File
@@ -1,3 +1,4 @@
import { INIT_USERS } from "@/lib/users"
import {
asns,
domains,
@@ -36,6 +37,7 @@ export function mockSidebarBadgesByUrl(): Record<string, string> {
"/ip-ranges": formatSidebarBadgeCount(ipRanges.length),
"/asns": formatSidebarBadgeCount(asns.length),
"/servers": formatSidebarBadgeCount(servers.length),
"/users": formatSidebarBadgeCount(INIT_USERS.length),
"/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/gre": formatSidebarBadgeCount(greTunnels.length),
@@ -54,4 +56,5 @@ export interface SidebarCountsDto {
recursiveRoutes: number
certificates?: number
wireguard?: number
users?: number
}
+291
View File
@@ -0,0 +1,291 @@
import { servers } from "@/lib/data"
import type { InterfaceType, PermLevel, AppUserRole as Role } from "@mmapp/contracts/users"
export type { InterfaceType, PermLevel, Role }
export interface SectionPerm { section: string; level: PermLevel }
export interface ServerPerm { serverId: string; level: PermLevel }
export interface InterfaceBinding {
id: string
userId: string
serverId: string
serverName: string
serverSite: string
serverCountry: string
interfaceName: string
interfaceType: InterfaceType
comment: string
}
export interface AppUserForm {
name: string
login: string
email: string
role: Role
active: boolean
sections: SectionPerm[]
servers: ServerPerm[]
bindings: InterfaceBinding[]
}
export interface AppUser {
id: string
name: string
login: string
email: string
role: Role
last: string
avatar: string
active: boolean
sections: SectionPerm[]
servers: ServerPerm[]
bindings: InterfaceBinding[]
}
export interface CatalogIface {
name: string
type: InterfaceType
running: boolean
disabled: boolean
boundUserId: string | null
boundUserLogin: string | null
}
export interface UserServerOption {
id: string
name: string
host: string
site: string
country: string
status?: "online" | "offline" | "degraded" | null
}
export const IFACE_TYPE_LABEL: Record<InterfaceType, string> = {
ether: "Ethernet",
gre: "GRE",
wg: "WireGuard",
other: "Прочие",
}
export const IFACE_TYPE_ORDER: InterfaceType[] = ["ether", "gre", "wg", "other"]
export const ROLE_LABEL: Record<Role, string> = {
admin: "Администратор",
operator: "Оператор",
viewer: "Наблюдатель",
}
export const ROLE_COLOR: Record<Role, string> = {
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
viewer: "bg-muted text-muted-foreground border border-border",
}
export const PERM_OPTS: { v: PermLevel; label: string }[] = [
{ v: "none", label: "Нет" },
{ v: "read", label: "Просмотр" },
{ v: "write", label: "Управление" },
]
export const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-success/10 text-success",
}
export const SECTION_GROUP_DEFS: { group: string; items: string[] }[] = [
{ group: "Обзор", items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
{ group: "Данные", items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", items: ["Оповещения", "Сбор данных", "Пользователи", "Настройки"] },
]
export const ALL_SECTIONS = SECTION_GROUP_DEFS.flatMap((g) => g.items)
export function defaultSections(role: Role): SectionPerm[] {
return ALL_SECTIONS.map((section) => {
let level: PermLevel = "none"
if (role === "admin") level = "write"
else if (role === "operator") {
level = ["Серверы", "Пользователи", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы", "Диагностика GRE", "Оптимизатор маршрутов", "OSPF"].includes(section)
? "write"
: "read"
} else if (role === "viewer") {
level = ["Настройки", "Терминал"].includes(section) ? "none" : "read"
}
return { section, level }
})
}
export function defaultServers(role: Role, serverList: { id: string }[] = servers): ServerPerm[] {
return serverList.map((s) => ({
serverId: s.id,
level: role === "admin" ? "write" : "read",
}))
}
export function userInitials(name: string): string {
return name.trim().split(/\s+/).map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
export const MOCK_IFACE_CATALOG: Record<string, CatalogIface[]> = {
srv1: [
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "ether2", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "d.fedorov@company.io" },
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "i.petrov@company.io" },
{ name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
],
srv7: [
{ name: "ether1", type: "ether", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
{ name: "gre-office-msk", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "a.korotaev@company.io" },
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "d.fedorov@company.io" },
{ name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null },
],
}
function bind(
id: string,
userId: string,
serverId: string,
interfaceName: string,
interfaceType: InterfaceType,
comment: string,
): InterfaceBinding {
const srv = servers.find((s) => s.id === serverId)
return {
id,
userId,
serverId,
serverName: srv?.name ?? serverId,
serverSite: srv?.site ?? "—",
serverCountry: srv?.country ?? "UN",
interfaceName,
interfaceType,
comment,
}
}
export const INIT_USERS: AppUser[] = [
{
id: "u1", name: "Александр Коротаев", login: "a.korotaev@company.io", email: "a.korotaev@company.io",
role: "admin", last: "сейчас", avatar: "АК", active: true,
sections: defaultSections("admin"), servers: defaultServers("admin"),
bindings: [
bind("b1", "u1", "srv1", "ether1", "ether", "Uplink MSK"),
bind("b2", "u1", "srv1", "gre-office-msk", "gre", "Офис MSK"),
bind("b3", "u1", "srv1", "gre-datacenter", "gre", "ЦОД"),
bind("b4", "u1", "srv1", "wg-msk-spb", "wg", "Overlay SPB"),
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
],
},
{
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov@company.io", email: "d.fedorov@company.io",
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
sections: defaultSections("operator"), servers: defaultServers("operator"),
bindings: [
bind("b7", "u2", "srv1", "gre-spb-branch", "gre", "Филиал SPB"),
bind("b8", "u2", "srv7", "gre-spb-branch", "gre", "Филиал LAB"),
],
},
{
id: "u3", name: "Мария Соколова", login: "m.sokolova@company.io", email: "m.sokolova@company.io",
role: "viewer", last: "вчера", avatar: "МС", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
bindings: [],
},
{
id: "u4", name: "Игорь Петров", login: "i.petrov@company.io", email: "i.petrov@company.io",
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
sections: defaultSections("operator"), servers: defaultServers("operator"),
bindings: [
bind("b9", "u4", "srv1", "gre-retail-01", "gre", "Магазин #1"),
],
},
]
export function catalogForServer(serverId: string, users: AppUser[]): CatalogIface[] {
const base = MOCK_IFACE_CATALOG[serverId] ?? []
return base.map((iface) => {
const owner = users.find((u) =>
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name),
)
if (!owner) return { ...iface, boundUserId: null, boundUserLogin: null }
return { ...iface, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
})
}
export function groupBindingsByServer(bindings: InterfaceBinding[]): Array<{
serverId: string
serverName: string
serverSite: string
serverCountry: string
types: Array<{ type: InterfaceType; items: InterfaceBinding[] }>
}> {
const byServer = new Map<string, InterfaceBinding[]>()
for (const b of bindings) {
const arr = byServer.get(b.serverId) ?? []
arr.push(b)
byServer.set(b.serverId, arr)
}
return [...byServer.entries()].map(([serverId, items]) => {
const first = items[0]
const types = IFACE_TYPE_ORDER
.map((type) => ({ type, items: items.filter((i) => i.interfaceType === type) }))
.filter((g) => g.items.length > 0)
return {
serverId,
serverName: first?.serverName ?? serverId,
serverSite: first?.serverSite ?? "—",
serverCountry: first?.serverCountry ?? "UN",
types,
}
})
}
export type UserAccessWriteKind = "full" | "none" | "partial"
export interface UserAccessSummary {
sectionsGranted: number
sectionsTotal: number
serversGranted: number
serversTotal: number
writeKind: UserAccessWriteKind
writeSections: string[]
}
export function summarizeUserAccess(
user: Pick<AppUser, "role" | "sections" | "servers">,
serversTotal: number,
sectionsTotal = ALL_SECTIONS.length,
): UserAccessSummary {
const isAdmin = user.role === "admin"
const sectionsGranted = isAdmin
? sectionsTotal
: user.sections.filter((s) => s.level !== "none").length
const serversGranted = isAdmin
? serversTotal
: user.servers.filter((s) => s.level !== "none").length
const writeSections = isAdmin
? []
: user.sections.filter((s) => s.level === "write").map((s) => s.section)
const writeKind: UserAccessWriteKind = isAdmin
? "full"
: writeSections.length === 0
? "none"
: "partial"
return {
sectionsGranted,
sectionsTotal,
serversGranted,
serversTotal,
writeKind,
writeSections,
}
}
+4
View File
@@ -37,6 +37,10 @@
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
},
"./users": {
"types": "./dist/users.d.ts",
"default": "./dist/users.js"
}
},
"dependencies": {
+1
View File
@@ -4,3 +4,4 @@ export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
export * from "./users.js"
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod"
export const appUserRoleSchema = z.enum(["admin", "operator", "viewer"])
export const permLevelSchema = z.enum(["none", "read", "write"])
export const interfaceTypeSchema = z.enum(["ether", "gre", "wg", "other"])
export const sectionPermSchema = z.object({
section: z.string().min(1),
level: permLevelSchema,
})
export const serverPermSchema = z.object({
serverId: z.string().min(1),
level: permLevelSchema,
})
export const userBindingSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
serverId: z.number().int().positive(),
serverName: z.string(),
serverSite: z.string(),
serverCountry: z.string(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema,
comment: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserReadSchema = z.object({
id: z.string().min(1),
name: z.string(),
login: z.string(),
email: z.string(),
role: appUserRoleSchema,
active: z.boolean(),
avatar: z.string(),
lastSeen: z.string().nullable(),
sections: z.array(sectionPermSchema),
servers: z.array(serverPermSchema),
bindings: z.array(userBindingSchema),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserCreateSchema = z.object({
name: z.string().min(1),
login: z.string().min(1),
email: z.string().min(1),
role: appUserRoleSchema.default("viewer"),
active: z.boolean().default(true),
avatar: z.string().optional(),
lastSeen: z.string().nullable().optional(),
sections: z.array(sectionPermSchema).default([]),
servers: z.array(serverPermSchema).default([]),
})
export const appUserUpdateSchema = appUserCreateSchema.partial()
export const appUserIdParamSchema = z.object({
id: z.string().min(1),
})
export const bindingIdParamSchema = z.object({
id: z.string().min(1),
bindingId: z.string().min(1),
})
export const userBindingCreateSchema = z.object({
serverId: z.coerce.number().int().positive(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema.optional(),
comment: z.string().optional(),
})
export const interfaceCatalogQuerySchema = z.object({
serverId: z.coerce.number().int().positive(),
})
export const catalogInterfaceSchema = z.object({
name: z.string(),
type: interfaceTypeSchema,
running: z.boolean(),
disabled: z.boolean(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
})
export const appUserListSchema = z.array(appUserReadSchema)
export type AppUserRole = z.infer<typeof appUserRoleSchema>
export type PermLevel = z.infer<typeof permLevelSchema>
export type InterfaceType = z.infer<typeof interfaceTypeSchema>
export type SectionPerm = z.infer<typeof sectionPermSchema>
export type ServerPerm = z.infer<typeof serverPermSchema>
export type UserBinding = z.infer<typeof userBindingSchema>
export type AppUserRead = z.infer<typeof appUserReadSchema>
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
+105
View File
@@ -0,0 +1,105 @@
import {
appUserReadSchema,
catalogInterfaceSchema,
userBindingSchema,
type AppUserCreate,
type AppUserRead,
type AppUserUpdate,
type CatalogInterface,
type UserBinding,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import { z } from "zod"
import { requestJson } from "@/shared/api/http-client"
import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users"
const usersListPayload = z.object({ users: z.array(appUserReadSchema) })
const userPayload = z.object({ user: appUserReadSchema })
const bindingPayload = z.object({ binding: userBindingSchema })
const catalogPayload = z.object({ interfaces: z.array(catalogInterfaceSchema) })
export function toFrontendBinding(b: UserBinding): InterfaceBinding {
return {
id: b.id,
userId: b.userId,
serverId: String(b.serverId),
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
comment: b.comment,
}
}
export function toFrontendUser(u: AppUserRead): AppUser {
return {
id: u.id,
name: u.name,
login: u.login,
email: u.email,
role: u.role,
last: u.lastSeen ?? "—",
avatar: u.avatar,
active: u.active,
sections: u.sections,
servers: u.servers,
bindings: u.bindings.map(toFrontendBinding),
}
}
export async function listAppUsers(baseUrl: string): Promise<AppUser[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/users")
return usersListPayload.parse(payload).users.map(toFrontendUser)
}
export async function createAppUser(baseUrl: string, data: AppUserCreate): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, "/api/users", {
method: "POST",
body: JSON.stringify(data),
})
return toFrontendUser(userPayload.parse(payload).user)
}
export async function updateAppUser(baseUrl: string, id: string, data: AppUserUpdate): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
})
return toFrontendUser(userPayload.parse(payload).user)
}
export async function deleteAppUser(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/users/${id}`, { method: "DELETE" })
}
export async function createUserBinding(
baseUrl: string,
userId: string,
data: UserBindingCreate,
): Promise<InterfaceBinding> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${userId}/bindings`, {
method: "POST",
body: JSON.stringify(data),
})
return toFrontendBinding(bindingPayload.parse(payload).binding)
}
export async function deleteUserBinding(
baseUrl: string,
userId: string,
bindingId: string,
): Promise<void> {
await requestJson<void>(baseUrl, `/api/users/${userId}/bindings/${bindingId}`, { method: "DELETE" })
}
export async function listInterfaceCatalog(
baseUrl: string,
serverId: string,
): Promise<CatalogIface[]> {
const payload = await requestJson<unknown>(
baseUrl,
`/api/users/interface-catalog?serverId=${encodeURIComponent(serverId)}`,
)
return catalogPayload.parse(payload).interfaces as CatalogInterface[]
}
+1 -1
View File
File diff suppressed because one or more lines are too long