Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f |
@@ -54,6 +54,7 @@ import {
|
||||
type SchedulerJobGridRow,
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -1210,6 +1211,8 @@ export default function DataCollectionPage() {
|
||||
</div>
|
||||
</DataPageCard>
|
||||
|
||||
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||
|
||||
<OpsPanel
|
||||
title="Журнал прогонов"
|
||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
role: "viewer", last: "вчера", avatar: "МС", active: true,
|
||||
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
|
||||
subUsers: [],
|
||||
},
|
||||
{
|
||||
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "[email protected]",
|
||||
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="[email protected]" 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+375
-222
@@ -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"
|
||||
@@ -11,13 +11,28 @@ import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 { getTrafficFlows } from "@/shared/api/traffic-flow"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
|
||||
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import type { FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
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 +52,21 @@ 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
|
||||
peerPublicKey?: string
|
||||
peerName?: string
|
||||
comment: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverSite: string
|
||||
serverCountry: string
|
||||
clientIp: string
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
@@ -75,7 +93,7 @@ interface ServerTraffic {
|
||||
sessions: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
greClients: GreClientTraffic[]
|
||||
boundIfaces: BoundIfaceTraffic[]
|
||||
}
|
||||
|
||||
interface UserTraffic {
|
||||
@@ -83,7 +101,8 @@ interface UserTraffic {
|
||||
login: string
|
||||
displayName: string
|
||||
role: string
|
||||
greClients: GreClientTraffic[]
|
||||
active?: boolean
|
||||
interfaces: BoundIfaceTraffic[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
@@ -125,129 +144,108 @@ 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 boundIfaceLabel(c: Pick<BoundIfaceTraffic, "interfaceName" | "interfaceType" | "peerName">): string {
|
||||
if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}`
|
||||
return c.interfaceName
|
||||
}
|
||||
|
||||
// ─── mock server data (with GRE clients attached) ─────────────────────────────
|
||||
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}:${b.peerPublicKey ?? "_iface"}`,
|
||||
bindingId: b.id,
|
||||
userId: u.id,
|
||||
userLogin: u.login,
|
||||
userName: u.name,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
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",
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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 +255,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 +283,13 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
|
||||
"24h": "24ч",
|
||||
}
|
||||
|
||||
type GroupMode = "servers" | "users" | "gre"
|
||||
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
||||
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 +349,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 +360,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 +378,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 +396,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 +413,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">{boundIfaceLabel(c)}</p>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,29 +442,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">{boundIfaceLabel(c)}</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 +469,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 +483,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 +496,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 +609,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 +624,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 +637,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 +645,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 +662,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">{boundIfaceLabel(sel)}</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 +686,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 +704,18 @@ 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: "Интерфейсы" },
|
||||
{ mode: "flows", icon: <GitBranchIcon 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 +737,12 @@ 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 [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
||||
const [overlayOpen, setOverlayOpen] = useState(false)
|
||||
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||||
const effectiveMode: GroupMode = groupMode
|
||||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||||
backendUrl,
|
||||
@@ -767,7 +766,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 +793,72 @@ 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 loadFlows = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
setLiveBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
const stats = await getTrafficFlows(backendUrl, range)
|
||||
setFlowStats(stats)
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
||||
} finally {
|
||||
setLiveBusy(false)
|
||||
}
|
||||
}, [isLive, backendUrl, range])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || effectiveMode !== "flows") return
|
||||
void loadFlows()
|
||||
}, [isLive, effectiveMode, loadFlows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
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 +886,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 if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
||||
setSortField("rx")
|
||||
setSortDir("desc")
|
||||
setSearch("")
|
||||
@@ -864,83 +925,83 @@ 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.peerName ?? "").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))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Обзор" }, { label: "Трафик" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLiveTraffic(range) }}
|
||||
disabled={isLive && liveBusy}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
const flowKpiItems = [
|
||||
{
|
||||
id: "exporters",
|
||||
label: "Экспортёры",
|
||||
value: String(flowStats?.exportersOnline ?? 0),
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Байт/мин",
|
||||
value: flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—",
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
label: "Уник. src",
|
||||
value: String(flowStats?.uniqueSrc ?? 0),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
label: "Топ протокол",
|
||||
value: flowStats?.topProto ?? "—",
|
||||
icon: <GitBranchIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]
|
||||
|
||||
{/* ── mode tab bar ── */}
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{GROUP_MODES.filter(m => !isLive || m.mode === "servers").map(({ mode, icon, label }) => (
|
||||
<button key={mode} onClick={() => handleModeChange(mode)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
effectiveMode === mode
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
items={[
|
||||
const counterKpiItems = [
|
||||
{
|
||||
id: "rx",
|
||||
label: "RX сейчас",
|
||||
@@ -969,11 +1030,102 @@ export default function TrafficPage() {
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Обзор" }, { label: "Трафик" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveMode === "flows") void loadFlows()
|
||||
else void loadLiveTraffic(range)
|
||||
}}
|
||||
disabled={isLive && liveBusy}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── mode tab bar ── */}
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{GROUP_MODES.map(({ mode, icon, label }) => (
|
||||
<button key={mode} onClick={() => handleModeChange(mode)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
effectiveMode === mode
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
|
||||
{effectiveMode === "flows" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
IPFIX top-разговоры. Счётчики интерфейсов — в режимах Серверы / Клиенты / Интерфейсы.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
{TRAFFIC_RANGE_KEYS.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setRange(key)}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||||
range === key
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{TRAFFIC_RANGE_LABELS[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
|
||||
<PlusIcon className="size-4" />
|
||||
Подключить JH
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{liveError && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{liveError}
|
||||
</div>
|
||||
)}
|
||||
<DataPageCard>
|
||||
<TrafficFlowsDataGrid rows={flowStats?.talkers ?? []} />
|
||||
</DataPageCard>
|
||||
<FlowOverlaySheet
|
||||
open={overlayOpen}
|
||||
onOpenChange={setOverlayOpen}
|
||||
servers={catalogServers}
|
||||
backendUrl={backendUrl}
|
||||
onDone={() => { void loadFlows() }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
{/* ── left panel ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@@ -1018,8 +1170,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,12 +1238,13 @@ 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>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"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,
|
||||
bindingDiffKey,
|
||||
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(bindingDiffKey))
|
||||
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||
for (const b of prev) {
|
||||
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||
await deleteUserBinding(backendUrl, userId, b.id)
|
||||
}
|
||||
}
|
||||
for (const b of next) {
|
||||
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||
await createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId),
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey,
|
||||
peerName: b.peerName,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
"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:traffic-flow": "tsx src/services/traffic-flow-parse.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",
|
||||
|
||||
@@ -106,6 +106,7 @@ CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TEXT NOT NULL,
|
||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -120,6 +121,47 @@ CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id INTEGER,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
last_datagram_at TEXT,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received INTEGER NOT NULL DEFAULT 0,
|
||||
peers_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 flow_buckets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
bucket_at TEXT NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes INTEGER NOT NULL DEFAULT 0,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
|
||||
ON flow_buckets(server_id, bucket_at, src, dst, proto, src_port, dst_port);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
|
||||
ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -511,9 +553,102 @@ 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',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
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, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user
|
||||
ON user_interface_bindings(user_id);
|
||||
`)
|
||||
|
||||
// Lightweight schema evolution for existing databases without migrations
|
||||
{
|
||||
const sampleCols = sqlite.prepare(`PRAGMA table_info('traffic_samples')`).all() as Array<{ name?: string }>
|
||||
if (!sampleCols.some((c) => c.name === "peer_public_key")) {
|
||||
sqlite.exec(`ALTER TABLE traffic_samples ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const bindCols = sqlite.prepare(`PRAGMA table_info('user_interface_bindings')`).all() as Array<{ name?: string }>
|
||||
if (!bindCols.some((c) => c.name === "peer_public_key")) {
|
||||
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_public_key TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
if (!bindCols.some((c) => c.name === "peer_name")) {
|
||||
sqlite.exec(`ALTER TABLE user_interface_bindings ADD COLUMN peer_name TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
const indexes = sqlite.prepare(`PRAGMA index_list('user_interface_bindings')`).all() as Array<{
|
||||
name?: string
|
||||
unique?: number
|
||||
}>
|
||||
let hasPeerUnique = false
|
||||
for (const idx of indexes) {
|
||||
if (!idx.name || !idx.unique) continue
|
||||
const info = sqlite.prepare(`PRAGMA index_info(${JSON.stringify(idx.name)})`).all() as Array<{ name?: string }>
|
||||
const names = info.map((c) => c.name)
|
||||
if (names.includes("server_id") && names.includes("interface_name") && names.includes("peer_public_key")) {
|
||||
hasPeerUnique = true
|
||||
}
|
||||
}
|
||||
if (!hasPeerUnique) {
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`)
|
||||
sqlite.exec(`
|
||||
CREATE TABLE user_interface_bindings_new (
|
||||
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',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
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, peer_public_key)
|
||||
);
|
||||
INSERT INTO user_interface_bindings_new
|
||||
(id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name, comment, created_at, updated_at)
|
||||
SELECT id, user_id, server_id, interface_name, interface_type,
|
||||
COALESCE(peer_public_key, ''), COALESCE(peer_name, ''), comment, created_at, updated_at
|
||||
FROM user_interface_bindings;
|
||||
DROP TABLE user_interface_bindings;
|
||||
ALTER TABLE user_interface_bindings_new RENAME TO user_interface_bindings;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
`)
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
}
|
||||
|
||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
||||
if (!hasCountryColumn) {
|
||||
@@ -580,6 +715,9 @@ if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
||||
}
|
||||
if (!serverCols.some((c) => c.name === "mgmt_tunnel_ip")) {
|
||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN mgmt_tunnel_ip TEXT NOT NULL DEFAULT ''`)
|
||||
}
|
||||
|
||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
||||
@@ -625,6 +763,12 @@ SELECT 1, 1, 30, 14
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO traffic_flow_settings (id, enabled, collector_ip, flow_listen_port, wg_listen_port, prefix)
|
||||
SELECT 1, 0, '10.255.254.1', 4739, 51821, '10.255.254.0/24'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_flow_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
||||
SELECT 1, 1, 15, 14
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
real,
|
||||
sqliteTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core"
|
||||
|
||||
// ── servers ────────────────────────────────────────────────────────────────────
|
||||
@@ -31,6 +32,8 @@ export const servers = sqliteTable("servers", {
|
||||
lanSubnet: text("lan_subnet").notNull().default(""),
|
||||
/** JSON-массив WAN-аплинков [{ id, name, isp, iface, ip, maxDl, maxUl }, …] */
|
||||
wanUplinks: text("wan_uplinks").notNull().default("[]"),
|
||||
/** Адрес в оверлее wg-flow (экспортёр IPFIX), например 10.255.254.5 */
|
||||
mgmtTunnelIp: text("mgmt_tunnel_ip").notNull().default(""),
|
||||
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
@@ -157,12 +160,55 @@ export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
|
||||
|
||||
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||
|
||||
export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||
collectorIp: text("collector_ip").notNull().default("10.255.254.1"),
|
||||
flowListenPort: integer("flow_listen_port").notNull().default(4739),
|
||||
wgListenPort: integer("wg_listen_port").notNull().default(51821),
|
||||
prefix: text("prefix").notNull().default("10.255.254.0/24"),
|
||||
publicEndpoint: text("public_endpoint").notNull().default(""),
|
||||
hostPublicKey: text("host_public_key").notNull().default(""),
|
||||
hostPrivateKey: text("host_private_key").notNull().default(""),
|
||||
hubServerId: integer("hub_server_id"),
|
||||
retentionHours: integer("retention_hours").notNull().default(24),
|
||||
topN: integer("top_n").notNull().default(200),
|
||||
lastDatagramAt: text("last_datagram_at"),
|
||||
lastExporterIp: text("last_exporter_ip"),
|
||||
lastError: text("last_error"),
|
||||
packetsReceived: integer("packets_received").notNull().default(0),
|
||||
peersJson: text("peers_json").notNull().default("[]"),
|
||||
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const flowBuckets = sqliteTable("flow_buckets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: text("bucket_at").notNull(),
|
||||
src: text("src").notNull(),
|
||||
dst: text("dst").notNull(),
|
||||
proto: integer("proto").notNull().default(0),
|
||||
srcPort: integer("src_port").notNull().default(0),
|
||||
dstPort: integer("dst_port").notNull().default(0),
|
||||
bytes: integer("bytes").notNull().default(0),
|
||||
packets: integer("packets").notNull().default(0),
|
||||
inIface: text("in_iface").notNull().default(""),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_flow_buckets_unique").on(
|
||||
t.serverId, t.bucketAt, t.src, t.dst, t.proto, t.srcPort, t.dstPort,
|
||||
),
|
||||
])
|
||||
|
||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
rxBytes: integer("rx_bytes").notNull().default(0),
|
||||
txBytes: integer("tx_bytes").notNull().default(0),
|
||||
@@ -540,6 +586,44 @@ 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"),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
peerName: text("peer_name").notNull().default(""),
|
||||
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_peer").on(t.serverId, t.interfaceName, t.peerPublicKey),
|
||||
])
|
||||
|
||||
export const internetPathSnapshots = sqliteTable("internet_path_snapshots", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sampledAt: text("sampled_at").notNull(),
|
||||
@@ -555,6 +639,8 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||
export type TrafficFlowSettingsRow = typeof trafficFlowSettings.$inferSelect
|
||||
export type FlowBucketRow = typeof flowBuckets.$inferSelect
|
||||
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||
@@ -582,3 +668,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
|
||||
|
||||
@@ -10,6 +10,7 @@ import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||
import uptimeRoutes from "./routes/uptime.js"
|
||||
import networkRoutes from "./routes/network.js"
|
||||
@@ -25,7 +26,9 @@ 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"
|
||||
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
@@ -92,6 +95,7 @@ export async function buildApp(opts?: {
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
await app.register(trafficRoutes, { prefix: "/api" })
|
||||
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||
await app.register(networkRoutes, { prefix: "/api" })
|
||||
@@ -107,11 +111,14 @@ 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()
|
||||
startTrafficFlowListener()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
stopTrafficFlowListener()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,90 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Database from "better-sqlite3"
|
||||
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||
|
||||
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',
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
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, peer_public_key)
|
||||
);
|
||||
`)
|
||||
|
||||
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(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||
`).run()
|
||||
sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||
`).run()
|
||||
assert.throws(
|
||||
() => sqlite.prepare(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
|
||||
`).run(),
|
||||
/UNIQUE/i,
|
||||
"один пир — один пользователь",
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => normalizeBindingPeer("wg", ""),
|
||||
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||
"WG без ключа — 400",
|
||||
)
|
||||
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||
|
||||
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, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||
|
||||
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")
|
||||
@@ -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,44 @@
|
||||
import type { InterfaceType } from "./iface-type.js"
|
||||
|
||||
export class PeerBindError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "PeerBindError"
|
||||
}
|
||||
}
|
||||
|
||||
export function truncPeerKey(key: string): string {
|
||||
const k = key.trim()
|
||||
if (k.length <= 20) return k
|
||||
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||
}
|
||||
|
||||
export function peerDisplayName(opts: {
|
||||
publicKey: string
|
||||
name?: string | null
|
||||
comment?: string | null
|
||||
}): string {
|
||||
const name = (opts.name ?? "").trim()
|
||||
if (name) return name
|
||||
const comment = (opts.comment ?? "").trim()
|
||||
if (comment) return comment
|
||||
return truncPeerKey(opts.publicKey)
|
||||
}
|
||||
|
||||
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
|
||||
export function normalizeBindingPeer(
|
||||
type: InterfaceType,
|
||||
peerPublicKey: string | undefined,
|
||||
): string {
|
||||
const key = (peerPublicKey ?? "").trim()
|
||||
if (type === "wg") {
|
||||
if (!key) {
|
||||
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||
}
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 getBindingByServerIfacePeer(
|
||||
serverId: number,
|
||||
interfaceName: string,
|
||||
peerPublicKey = "",
|
||||
): BindingRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
.from(userInterfaceBindings)
|
||||
.where(and(
|
||||
eq(userInterfaceBindings.serverId, serverId),
|
||||
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||
))
|
||||
.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,343 @@
|
||||
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,
|
||||
getBindingByServerIfacePeer,
|
||||
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"
|
||||
import {
|
||||
normalizeBindingPeer,
|
||||
PeerBindError,
|
||||
peerDisplayName,
|
||||
} from "../peer-bind.js"
|
||||
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.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,
|
||||
peerPublicKey: row.peerPublicKey ?? "",
|
||||
peerName: row.peerName ?? "",
|
||||
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 type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
|
||||
let peerPublicKey = ""
|
||||
try {
|
||||
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||
} catch (err) {
|
||||
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||
throw err
|
||||
}
|
||||
const peerName = type === "wg"
|
||||
? peerDisplayName({
|
||||
publicKey: peerPublicKey,
|
||||
name: input.peerName,
|
||||
})
|
||||
: ""
|
||||
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||
if (taken) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
409,
|
||||
)
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
const row = createBindingRow({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
serverId: input.serverId,
|
||||
interfaceName: ifaceName,
|
||||
interfaceType: type,
|
||||
peerPublicKey,
|
||||
peerName,
|
||||
comment: (input.comment ?? "").trim(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
return toBindingDto(row)
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
throw new UsersServiceError(
|
||||
type === "wg"
|
||||
? "Этот пир уже привязан к другому пользователю"
|
||||
: "Интерфейс уже привязан к другому пользователю",
|
||||
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 async function listInterfaceCatalog(serverId: number): Promise<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,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
running: trafficSamples.running,
|
||||
disabled: trafficSamples.disabled,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.all()
|
||||
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||
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]))
|
||||
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||
const wgLive = hasWg
|
||||
? await listWireGuardPeersForCatalog(serverId)
|
||||
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||
for (const peer of wgLive.peers) {
|
||||
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||
list.push(peer)
|
||||
peersByIface.set(peer.interfaceName, list)
|
||||
}
|
||||
|
||||
return ifaces.map((iface) => {
|
||||
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||
const base: CatalogInterface = {
|
||||
name: iface.name,
|
||||
type: iface.type,
|
||||
running: iface.running,
|
||||
disabled: iface.disabled,
|
||||
boundUserId: ifaceBind?.userId ?? null,
|
||||
boundUserLogin: owner?.login ?? null,
|
||||
}
|
||||
if (iface.type !== "wg") return base
|
||||
const livePeers = peersByIface.get(iface.name) ?? []
|
||||
return {
|
||||
...base,
|
||||
peersError: wgLive.error,
|
||||
peers: livePeers.map((p) => {
|
||||
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||
return {
|
||||
publicKey: p.publicKey,
|
||||
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||
comment: p.comment,
|
||||
allowedIps: p.allowedIps,
|
||||
latestHandshake: p.latestHandshake,
|
||||
boundUserId: bind?.userId ?? null,
|
||||
boundUserLogin: peerOwner?.login ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export { parseRawInterfaces, mapRosInterfaceType }
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import {
|
||||
trafficFlowOverlayRequestSchema,
|
||||
trafficFlowSettingsPatchSchema,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
toTrafficFlowSettingsDto,
|
||||
updateTrafficFlowSettings,
|
||||
} from "../services/traffic-flow-settings.js"
|
||||
import {
|
||||
getFlowListenerState,
|
||||
listFlowTalkers,
|
||||
startTrafficFlowListener,
|
||||
} from "../services/traffic-flow-ingest.js"
|
||||
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
|
||||
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
|
||||
|
||||
function rangeToMinutes(range: string | undefined): number {
|
||||
switch ((range ?? "5m").toLowerCase()) {
|
||||
case "5m": return 5
|
||||
case "15m": return 15
|
||||
case "1h": return 60
|
||||
case "4h": return 240
|
||||
case "24h": return 1440
|
||||
default: return 5
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFlowTalkers(req: FastifyRequest, reply: FastifyReply) {
|
||||
const q = req.query as { range?: string }
|
||||
return reply.send(listFlowTalkers(rangeToMinutes(q.range)))
|
||||
}
|
||||
|
||||
function requestPublicHost(req: FastifyRequest): string {
|
||||
const forwarded = req.headers["x-forwarded-host"]
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||
return raw || req.hostname || ""
|
||||
}
|
||||
|
||||
async function applyOverlayHandler(req: FastifyRequest, reply: FastifyReply) {
|
||||
const parsed = trafficFlowOverlayRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
try {
|
||||
const result = await applyFlowOverlay(parsed.data.serverId, {
|
||||
publicEndpoint: parsed.data.publicEndpoint,
|
||||
requestHost: requestPublicHost(req),
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (e) {
|
||||
const status = (e as { statusCode?: number }).statusCode ?? 502
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(status).send({ error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/flow/settings", async (_req, reply) => {
|
||||
return reply.send(toTrafficFlowSettingsDto(getFlowListenerState()))
|
||||
})
|
||||
|
||||
app.put("/traffic/flow/settings", async (req, reply) => {
|
||||
const parsed = trafficFlowSettingsPatchSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
updateTrafficFlowSettings(parsed.data)
|
||||
startTrafficFlowListener()
|
||||
return reply.send({ ok: true, settings: toTrafficFlowSettingsDto(getFlowListenerState()) })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/settings/generate-keys", async (_req, reply) => {
|
||||
const result = ensureHostKeys()
|
||||
return reply.send({
|
||||
ok: true,
|
||||
created: result.created,
|
||||
publicKey: result.publicKey,
|
||||
settings: toTrafficFlowSettingsDto(getFlowListenerState()),
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/traffic/flow/host-files", async (_req, reply) => {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
if (!row.hostPrivateKey) ensureHostKeys()
|
||||
return reply.send({ files: listTrafficFlowHostFiles() })
|
||||
})
|
||||
|
||||
app.post("/traffic/flow/overlay", applyOverlayHandler)
|
||||
app.post("/traffic/flow-overlay", applyOverlayHandler)
|
||||
|
||||
app.get("/traffic/flow", sendFlowTalkers)
|
||||
app.get("/traffic/flows", sendFlowTalkers)
|
||||
}
|
||||
|
||||
export default trafficFlowRoutes
|
||||
@@ -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
|
||||
|
||||
@@ -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: await 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
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
getEnabledServerById,
|
||||
listWireGuardInterfaces,
|
||||
} from "../services/wireguard-live.js"
|
||||
import {
|
||||
putIpAddress,
|
||||
putWireguardInterface,
|
||||
putWireguardPeer,
|
||||
toRosBody,
|
||||
} from "../services/wireguard-ros.js"
|
||||
|
||||
function serverIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
@@ -30,14 +36,6 @@ function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||
return toRosBody({
|
||||
interface: p.interfaceName,
|
||||
@@ -99,20 +97,17 @@ async function applyParsedConfig(
|
||||
comment: parsed.interface.comment,
|
||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||
})
|
||||
await client.put("/interface/wireguard", ifaceBody)
|
||||
await putWireguardInterface(client, ifaceBody)
|
||||
|
||||
if (parsed.interface.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: parsed.interface.address,
|
||||
interface: name,
|
||||
})
|
||||
await putIpAddress(client, parsed.interface.address, name)
|
||||
}
|
||||
|
||||
let peersCreated = 0
|
||||
for (const p of parsed.peers) {
|
||||
if (!p.publicKey) continue
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
await putWireguardPeer(
|
||||
client,
|
||||
peerToRosBody({
|
||||
interfaceName: name,
|
||||
publicKey: p.publicKey,
|
||||
@@ -164,30 +159,21 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put(
|
||||
"/interface/wireguard",
|
||||
toRosBody({
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
}),
|
||||
)
|
||||
await putWireguardInterface(client, {
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
})
|
||||
|
||||
if (body.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: body.address,
|
||||
interface: body.name,
|
||||
})
|
||||
await putIpAddress(client, body.address, body.name)
|
||||
}
|
||||
|
||||
if (body.peer) {
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
peerToRosBody({ ...body.peer, interfaceName: body.name }),
|
||||
)
|
||||
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
||||
}
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
@@ -255,7 +241,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put("/interface/wireguard/peers", peerToRosBody(body))
|
||||
await putWireguardPeer(client, peerToRosBody(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
|
||||
@@ -16,6 +16,38 @@ interface RosIfaceTraffic {
|
||||
"tx-bits-per-second"?: string
|
||||
}
|
||||
|
||||
interface RosWgPeerTraffic {
|
||||
interface?: string
|
||||
name?: string
|
||||
comment?: string
|
||||
"public-key"?: string
|
||||
rx?: string
|
||||
tx?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
function waveKey(interfaceName: string, peerPublicKey = ""): string {
|
||||
return `${interfaceName}\0${peerPublicKey}`
|
||||
}
|
||||
|
||||
function sampleRate(
|
||||
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
|
||||
key: string,
|
||||
rxBytes: number,
|
||||
txBytes: number,
|
||||
nowMs: number,
|
||||
): { rxBps: number; txBps: number } {
|
||||
const prev = prevWave.get(key)
|
||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||
const rxBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const txBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
return { rxBps, txBps }
|
||||
}
|
||||
|
||||
export interface TrafficCollectorState {
|
||||
running: boolean
|
||||
lastRunAt: string | null
|
||||
@@ -63,6 +95,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
||||
const rows = db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
rxBytes: trafficSamples.rxBytes,
|
||||
txBytes: trafficSamples.txBytes,
|
||||
sampledAt: trafficSamples.sampledAt,
|
||||
@@ -73,7 +106,7 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
|
||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||
))
|
||||
.all()
|
||||
return new Map(rows.map((r) => [r.interfaceName, r]))
|
||||
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
@@ -116,14 +149,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
const txBytes = toNum(i["tx-byte"])
|
||||
const running = (i.running ?? "false") === "true"
|
||||
const disabled = (i.disabled ?? "false") === "true"
|
||||
const prev = prevWave.get(interfaceName)
|
||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||
const rxBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const txBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const { rxBps, txBps } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
|
||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||
sumRxMbps += bpsToMbps(rxBps)
|
||||
sumTxMbps += bpsToMbps(txBps)
|
||||
@@ -131,6 +157,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
return {
|
||||
serverId: srv.id,
|
||||
interfaceName,
|
||||
peerPublicKey: "",
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
@@ -140,6 +167,39 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
disabled,
|
||||
}
|
||||
})
|
||||
try {
|
||||
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
|
||||
for (const p of peers) {
|
||||
const interfaceName = (p.interface ?? "").trim()
|
||||
const peerPublicKey = (p["public-key"] ?? "").trim()
|
||||
if (!interfaceName || !peerPublicKey) continue
|
||||
const rxBytes = toNum(p.rx)
|
||||
const txBytes = toNum(p.tx)
|
||||
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes"
|
||||
const running = !disabled
|
||||
const { rxBps, txBps } = sampleRate(
|
||||
prevWave,
|
||||
waveKey(interfaceName, peerPublicKey),
|
||||
rxBytes,
|
||||
txBytes,
|
||||
nowMs,
|
||||
)
|
||||
rows.push({
|
||||
serverId: srv.id,
|
||||
interfaceName,
|
||||
peerPublicKey,
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* WG peers optional — iface samples already recorded */
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
db.insert(trafficSamples).values(rows).run()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { generateNativeConf } from "./wireguard-config.js"
|
||||
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
|
||||
import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
export function buildHostWgQuickConf(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = listHostPeers()
|
||||
return generateNativeConf({
|
||||
name: "wg-flow",
|
||||
listenPort: row.wgListenPort,
|
||||
mtu: 1420,
|
||||
privateKey: row.hostPrivateKey || undefined,
|
||||
address: `${row.collectorIp}/24`,
|
||||
comment: "MikrotikManager traffic-flow collector",
|
||||
peers: peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
comment: p.name,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHostComposeSnippet(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return `# IPFIX listener: публиковать UDP только на WG-адресе хоста, не на 0.0.0.0
|
||||
# Поднимите wg-quick@wg-flow, затем раскомментируйте ports у backend.
|
||||
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "${row.collectorIp}:${row.flowListenPort}:${row.flowListenPort}/udp"
|
||||
environment:
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
`
|
||||
}
|
||||
|
||||
export function buildHostNftSnippet(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return `# Firewall хоста Docker MM (nftables). UDP ${row.flowListenPort} наружу НЕ открывать.
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority 0;
|
||||
iifname "wg-flow" udp dport ${row.flowListenPort} accept
|
||||
udp dport ${row.wgListenPort} accept comment "WireGuard handshake"
|
||||
udp dport ${row.flowListenPort} drop
|
||||
}
|
||||
}
|
||||
|
||||
# ufw (если используете):
|
||||
# ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'
|
||||
# ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'
|
||||
`
|
||||
}
|
||||
|
||||
export function buildHostUfwSnippet(): string {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return [
|
||||
`ufw allow ${row.wgListenPort}/udp comment 'mm-wg-flow'`,
|
||||
`ufw deny ${row.flowListenPort}/udp comment 'ipfix-not-public'`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
|
||||
return [
|
||||
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
|
||||
{ id: "compose", label: "docker-compose", filename: "docker-compose.flow.yml", code: buildHostComposeSnippet() },
|
||||
{ id: "nft", label: "nftables", filename: "wg-flow.nft", code: buildHostNftSnippet() },
|
||||
{ id: "ufw", label: "ufw", filename: "wg-flow.ufw.sh", code: buildHostUfwSnippet() },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { createSocket, type Socket } from "node:dgram"
|
||||
import { desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { flowBuckets, servers } from "../db/schema.js"
|
||||
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
|
||||
import {
|
||||
getTrafficFlowSettingsRow,
|
||||
recordFlowListenerError,
|
||||
recordFlowPacket,
|
||||
} from "./traffic-flow-settings.js"
|
||||
|
||||
export interface FlowListenerState {
|
||||
bound: boolean
|
||||
address: string | null
|
||||
}
|
||||
|
||||
let socket: Socket | null = null
|
||||
let state: FlowListenerState = { bound: false, address: null }
|
||||
const pending = new Map<string, {
|
||||
serverId: number
|
||||
bucketAt: string
|
||||
flow: ParsedFlow
|
||||
bytes: number
|
||||
packets: number
|
||||
}>()
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function getFlowListenerState(): FlowListenerState {
|
||||
return state
|
||||
}
|
||||
|
||||
function minuteBucketIso(at = Date.now()): string {
|
||||
const d = new Date(at)
|
||||
d.setSeconds(0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
function resolveServerId(exporterIp: string): number | null {
|
||||
const exact = db.select().from(servers).where(eq(servers.mgmtTunnelIp, exporterIp)).limit(1).all()[0]
|
||||
return exact ? exact.id : null
|
||||
}
|
||||
|
||||
function queueFlows(exporterIp: string, flows: ParsedFlow[]) {
|
||||
const serverId = resolveServerId(exporterIp)
|
||||
if (serverId == null) return
|
||||
const bucketAt = minuteBucketIso()
|
||||
for (const flow of flows) {
|
||||
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}`
|
||||
const prev = pending.get(key)
|
||||
if (prev) {
|
||||
prev.bytes += flow.bytes
|
||||
prev.packets += flow.packets
|
||||
} else {
|
||||
pending.set(key, {
|
||||
serverId,
|
||||
bucketAt,
|
||||
flow: { ...flow },
|
||||
bytes: flow.bytes,
|
||||
packets: flow.packets,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flushPending() {
|
||||
if (pending.size === 0) return
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
const topN = Math.max(20, settings.topN)
|
||||
const cutoff = new Date(Date.now() - settings.retentionHours * 3600_000).toISOString()
|
||||
const rows = [...pending.values()]
|
||||
pending.clear()
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
db.insert(flowBuckets).values({
|
||||
serverId: row.serverId,
|
||||
bucketAt: row.bucketAt,
|
||||
src: row.flow.src || "0.0.0.0",
|
||||
dst: row.flow.dst || "0.0.0.0",
|
||||
proto: row.flow.proto,
|
||||
srcPort: row.flow.srcPort,
|
||||
dstPort: row.flow.dstPort,
|
||||
bytes: row.bytes,
|
||||
packets: row.packets,
|
||||
inIface: row.flow.inIface,
|
||||
}).onConflictDoUpdate({
|
||||
target: [
|
||||
flowBuckets.serverId,
|
||||
flowBuckets.bucketAt,
|
||||
flowBuckets.src,
|
||||
flowBuckets.dst,
|
||||
flowBuckets.proto,
|
||||
flowBuckets.srcPort,
|
||||
flowBuckets.dstPort,
|
||||
],
|
||||
set: {
|
||||
bytes: sql`${flowBuckets.bytes} + excluded.bytes`,
|
||||
packets: sql`${flowBuckets.packets} + excluded.packets`,
|
||||
},
|
||||
}).run()
|
||||
} catch {
|
||||
// ignore single-row failures
|
||||
}
|
||||
}
|
||||
|
||||
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
|
||||
|
||||
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
|
||||
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
|
||||
if (!latest) return
|
||||
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
|
||||
const byServer = new Map<number, typeof latestRows>()
|
||||
for (const r of latestRows) {
|
||||
const list = byServer.get(r.serverId) ?? []
|
||||
list.push(r)
|
||||
byServer.set(r.serverId, list)
|
||||
}
|
||||
for (const list of byServer.values()) {
|
||||
if (list.length <= topN) continue
|
||||
list.sort((a, b) => b.bytes - a.bytes)
|
||||
for (const d of list.slice(topN)) {
|
||||
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(msg: Buffer, rinfo: { address: string }) {
|
||||
try {
|
||||
const flows = parseFlowPacket(msg, rinfo.address)
|
||||
recordFlowPacket(rinfo.address)
|
||||
if (flows.length) queueFlows(rinfo.address, flows)
|
||||
} catch (e) {
|
||||
recordFlowListenerError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTrafficFlowListener() {
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
flushPending()
|
||||
if (socket) {
|
||||
try { socket.close() } catch { /* ignore */ }
|
||||
socket = null
|
||||
}
|
||||
state = { bound: false, address: null }
|
||||
}
|
||||
|
||||
export function startTrafficFlowListener() {
|
||||
stopTrafficFlowListener()
|
||||
const settings = getTrafficFlowSettingsRow()
|
||||
if (!settings.enabled) {
|
||||
state = { bound: false, address: null }
|
||||
return
|
||||
}
|
||||
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
|
||||
const port = settings.flowListenPort
|
||||
const sock = createSocket("udp4")
|
||||
sock.on("error", (err) => {
|
||||
recordFlowListenerError(err.message)
|
||||
state = { bound: false, address: null }
|
||||
})
|
||||
sock.on("message", onMessage)
|
||||
sock.bind(port, host, () => {
|
||||
state = { bound: true, address: `${host}:${port}` }
|
||||
recordFlowListenerError("")
|
||||
})
|
||||
socket = sock
|
||||
flushTimer = setInterval(flushPending, 15_000)
|
||||
}
|
||||
|
||||
export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
||||
const rangeStart = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rows = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, rangeStart)).all()
|
||||
const serverRows = db.select().from(servers).all()
|
||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||
const protoBytes = new Map<number, number>()
|
||||
const srcs = new Set<string>()
|
||||
const dsts = new Set<string>()
|
||||
const exporters = new Set<number>()
|
||||
let totalBytes = 0
|
||||
for (const r of rows) {
|
||||
const key = `${r.serverId}|${r.src}|${r.dst}|${r.proto}|${r.srcPort}|${r.dstPort}`
|
||||
const prev = agg.get(key)
|
||||
const bytes = r.bytes
|
||||
totalBytes += bytes
|
||||
srcs.add(r.src)
|
||||
dsts.add(r.dst)
|
||||
exporters.add(r.serverId)
|
||||
protoBytes.set(r.proto, (protoBytes.get(r.proto) ?? 0) + bytes)
|
||||
if (prev) {
|
||||
prev.rawBytes += bytes
|
||||
prev.bytes += bytes
|
||||
prev.packets += r.packets
|
||||
} else {
|
||||
agg.set(key, {
|
||||
serverId: String(r.serverId),
|
||||
serverName: nameById.get(r.serverId) ?? String(r.serverId),
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
protoName: protoName(r.proto),
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes,
|
||||
packets: r.packets,
|
||||
bps: 0,
|
||||
inIface: r.inIface,
|
||||
rawBytes: bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
const windowSec = Math.max(60, minutes * 60)
|
||||
const talkers = [...agg.values()]
|
||||
.map((t) => ({ ...t, bps: (t.rawBytes * 8) / windowSec }))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, getTrafficFlowSettingsRow().topN)
|
||||
.map(({ rawBytes: _raw, ...rest }) => rest)
|
||||
let topProto = "—"
|
||||
let topProtoBytes = 0
|
||||
for (const [p, b] of protoBytes) {
|
||||
if (b > topProtoBytes) {
|
||||
topProtoBytes = b
|
||||
topProto = protoName(p)
|
||||
}
|
||||
}
|
||||
return {
|
||||
exportersOnline: exporters.size,
|
||||
bytesPerMin: minutes > 0 ? totalBytes / minutes : totalBytes,
|
||||
uniqueSrc: srcs.size,
|
||||
uniqueDst: dsts.size,
|
||||
topProto,
|
||||
talkers,
|
||||
}
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
|
||||
queueFlows(exporterIp, flows)
|
||||
flushPending()
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
import { MikrotikClient, MikrotikError } from "./mikrotik.js"
|
||||
import { getEnabledServerById, listWireGuardInterfaces } from "./wireguard-live.js"
|
||||
import {
|
||||
asRosArray,
|
||||
patchRosPath,
|
||||
putIpAddress,
|
||||
putWireguardInterface,
|
||||
putWireguardPeer,
|
||||
rosRowId,
|
||||
toRosBody,
|
||||
} from "./wireguard-ros.js"
|
||||
import {
|
||||
ensureHostKeys,
|
||||
getTrafficFlowSettingsRow,
|
||||
updateTrafficFlowSettings,
|
||||
upsertHostPeer,
|
||||
} from "./traffic-flow-settings.js"
|
||||
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
|
||||
|
||||
const IFACE_NAME = "wg-flow"
|
||||
const JH_LISTEN_PORT = 13232
|
||||
const WG_INPUT_COMMENT = "mm-wg-flow"
|
||||
|
||||
export function allocateOverlayAddress(prefix: string, collectorIp: string, serverId: number, taken: Set<string>): string {
|
||||
const [base] = prefix.split("/")
|
||||
const parts = (base ?? "10.255.254.0").split(".").map((n) => Number.parseInt(n, 10))
|
||||
const a = parts[0] || 10
|
||||
const b = parts[1] || 255
|
||||
const c = parts[2] || 254
|
||||
const preferredLast = 2 + ((serverId - 1) % 250)
|
||||
const candidates = [preferredLast, ...Array.from({ length: 253 }, (_, i) => 2 + ((preferredLast - 2 + i) % 253))]
|
||||
for (const last of candidates) {
|
||||
const ip = `${a}.${b}.${c}.${last}`
|
||||
if (ip === collectorIp) continue
|
||||
if (taken.has(ip)) continue
|
||||
return ip
|
||||
}
|
||||
throw new Error("Нет свободных адресов в префиксе wg-flow")
|
||||
}
|
||||
|
||||
function linuxPeerBlock(publicKey: string, address: string, comment: string): string {
|
||||
return [
|
||||
`[Peer]`,
|
||||
`PublicKey = ${publicKey}`,
|
||||
`AllowedIPs = ${address}/32`,
|
||||
comment ? `# ${comment}` : "",
|
||||
].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
async function findIface(client: MikrotikClient, name: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard"))
|
||||
return list.find((i) => String(i.name ?? "") === name)
|
||||
}
|
||||
|
||||
async function findPeer(
|
||||
client: MikrotikClient,
|
||||
iface: string,
|
||||
publicKey: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/interface/wireguard/peers"))
|
||||
return list.find((p) =>
|
||||
String(p.interface ?? "") === iface && String(p["public-key"] ?? "") === publicKey,
|
||||
)
|
||||
}
|
||||
|
||||
async function findAddress(client: MikrotikClient, iface: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/address"))
|
||||
return list.find((a) => String(a.interface ?? "") === iface)
|
||||
}
|
||||
|
||||
async function findRoute(client: MikrotikClient, dst: string): Promise<Record<string, unknown> | undefined> {
|
||||
const list = asRosArray<Record<string, unknown>>(await client.get("/ip/route"))
|
||||
return list.find((r) => String(r["dst-address"] ?? "") === dst)
|
||||
}
|
||||
|
||||
async function ensureWgInputAccept(client: MikrotikClient, listenPort: number): Promise<boolean> {
|
||||
const rules = asRosArray<Record<string, unknown>>(await client.get("/ip/firewall/filter"))
|
||||
const existing = rules.find((r) => String(r.comment ?? "") === WG_INPUT_COMMENT)
|
||||
if (existing) return false
|
||||
await client.put("/ip/firewall/filter", toRosBody({
|
||||
chain: "input",
|
||||
protocol: "udp",
|
||||
"dst-port": String(listenPort),
|
||||
action: "accept",
|
||||
comment: WG_INPUT_COMMENT,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
async function listFlowInterfaces(client: MikrotikClient): Promise<string> {
|
||||
const ifaces = asRosArray<{ name?: string; type?: string; disabled?: string }>(await client.get("/interface"))
|
||||
const names = ifaces
|
||||
.filter((i) => {
|
||||
if ((i.disabled ?? "false") === "true") return false
|
||||
const name = i.name ?? ""
|
||||
if (!name || name === IFACE_NAME || /^lo/i.test(name)) return false
|
||||
const type = (i.type ?? "").toLowerCase()
|
||||
return type.includes("ether") || type.includes("gre") || type === "vlan"
|
||||
})
|
||||
.map((i) => i.name ?? "")
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
return names.join(",") || "all"
|
||||
}
|
||||
|
||||
async function ensureTrafficFlow(client: MikrotikClient, collectorIp: string, port: number): Promise<void> {
|
||||
const interfaces = await listFlowInterfaces(client)
|
||||
try {
|
||||
await client.patch("/ip/traffic-flow", toRosBody({
|
||||
enabled: "yes",
|
||||
interfaces,
|
||||
"active-flow-timeout": "1m",
|
||||
"inactive-flow-timeout": "15s",
|
||||
}))
|
||||
} catch {
|
||||
await client.put("/ip/traffic-flow", toRosBody({
|
||||
enabled: "yes",
|
||||
interfaces,
|
||||
}))
|
||||
}
|
||||
|
||||
const targets = asRosArray<Record<string, unknown>>(await client.get("/ip/traffic-flow/target"))
|
||||
const existing = targets.find((t) => String(t["dst-address"] ?? "") === collectorIp)
|
||||
const body = toRosBody({
|
||||
"dst-address": collectorIp,
|
||||
port: String(port),
|
||||
version: "ipfix",
|
||||
})
|
||||
if (existing) {
|
||||
const id = rosRowId(existing)
|
||||
if (id) await patchRosPath(client, `/ip/traffic-flow/target/${encodeURIComponent(id)}`, body)
|
||||
return
|
||||
}
|
||||
await client.put("/ip/traffic-flow/target", body)
|
||||
}
|
||||
|
||||
export function usablePublicHost(raw: string | undefined): string {
|
||||
if (!raw) return ""
|
||||
const host = raw.split(",")[0]?.trim().replace(/^\[/, "").replace(/\]:\d+$/, "").split(":")[0]?.trim() ?? ""
|
||||
const lower = host.toLowerCase()
|
||||
if (!host) return ""
|
||||
if (lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "0.0.0.0") return ""
|
||||
if (lower.endsWith(".local") || lower.endsWith(".internal") || lower.endsWith(".lan")) return ""
|
||||
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return ""
|
||||
return host
|
||||
}
|
||||
|
||||
export async function applyFlowOverlay(
|
||||
serverIdRaw: string | number,
|
||||
opts?: { publicEndpoint?: string; requestHost?: string },
|
||||
): Promise<TrafficFlowOverlayResult> {
|
||||
const steps: string[] = []
|
||||
const keys = ensureHostKeys()
|
||||
let settings = getTrafficFlowSettingsRow()
|
||||
const hostPublicKey = settings.hostPublicKey || keys.publicKey
|
||||
if (!hostPublicKey) {
|
||||
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
|
||||
}
|
||||
|
||||
const endpointHost = (
|
||||
opts?.publicEndpoint?.trim()
|
||||
|| settings.publicEndpoint.trim()
|
||||
|| usablePublicHost(opts?.requestHost)
|
||||
).trim()
|
||||
if (!endpointHost) {
|
||||
throw Object.assign(new Error("Укажите публичный endpoint хоста MM (IP или DNS)"), { statusCode: 400 })
|
||||
}
|
||||
|
||||
const server = getEnabledServerById(String(serverIdRaw))
|
||||
if (!server || !server.enabled) {
|
||||
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
|
||||
}
|
||||
|
||||
if (endpointHost !== settings.publicEndpoint.trim()) {
|
||||
updateTrafficFlowSettings({ publicEndpoint: endpointHost })
|
||||
settings = getTrafficFlowSettingsRow()
|
||||
}
|
||||
|
||||
const taken = new Set(
|
||||
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
|
||||
.map((r) => r.ip)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
|
||||
try {
|
||||
let iface = await findIface(client, IFACE_NAME)
|
||||
if (!iface) {
|
||||
await putWireguardInterface(client, {
|
||||
name: IFACE_NAME,
|
||||
"listen-port": String(JH_LISTEN_PORT),
|
||||
mtu: "1420",
|
||||
comment: "MikrotikManager traffic-flow overlay",
|
||||
})
|
||||
steps.push(`Создан интерфейс ${IFACE_NAME}`)
|
||||
iface = await findIface(client, IFACE_NAME)
|
||||
} else {
|
||||
steps.push(`Интерфейс ${IFACE_NAME} уже есть`)
|
||||
}
|
||||
|
||||
const addrRow = await findAddress(client, IFACE_NAME)
|
||||
const mask = (settings.prefix.split("/")[1] || "24").replace(/\D/g, "") || "24"
|
||||
const cidr = `${address}/${mask}`
|
||||
if (!addrRow) {
|
||||
await putIpAddress(client, cidr, IFACE_NAME)
|
||||
steps.push(`Адрес ${cidr}`)
|
||||
} else {
|
||||
steps.push(`Адрес на ${IFACE_NAME} уже назначен`)
|
||||
}
|
||||
|
||||
const peer = await findPeer(client, IFACE_NAME, hostPublicKey)
|
||||
const peerBody = {
|
||||
interface: IFACE_NAME,
|
||||
"public-key": hostPublicKey,
|
||||
"allowed-address": `${settings.collectorIp}/32`,
|
||||
"endpoint-address": endpointHost,
|
||||
"endpoint-port": String(settings.wgListenPort),
|
||||
"persistent-keepalive": "25",
|
||||
comment: "MM traffic-flow collector",
|
||||
name: "mm-collector",
|
||||
}
|
||||
if (!peer) {
|
||||
await putWireguardPeer(client, peerBody)
|
||||
steps.push("Добавлен пир на pubkey хоста MM")
|
||||
} else {
|
||||
const id = rosRowId(peer)
|
||||
if (id) await patchRosPath(client, `/interface/wireguard/peers/${encodeURIComponent(id)}`, peerBody)
|
||||
steps.push("Пир хоста MM обновлён")
|
||||
}
|
||||
|
||||
const routeDst = `${settings.collectorIp}/32`
|
||||
const route = await findRoute(client, routeDst)
|
||||
if (!route) {
|
||||
await client.put("/ip/route", toRosBody({
|
||||
"dst-address": routeDst,
|
||||
gateway: IFACE_NAME,
|
||||
comment: "MM traffic-flow collector",
|
||||
}))
|
||||
steps.push(`Маршрут ${routeDst} через ${IFACE_NAME}`)
|
||||
} else {
|
||||
steps.push("Маршрут до collector уже есть")
|
||||
}
|
||||
|
||||
if (await ensureWgInputAccept(client, JH_LISTEN_PORT)) {
|
||||
steps.push(`Firewall input accept UDP ${JH_LISTEN_PORT}`)
|
||||
} else {
|
||||
steps.push("Firewall input WG уже есть")
|
||||
}
|
||||
|
||||
await ensureTrafficFlow(client, settings.collectorIp, settings.flowListenPort)
|
||||
steps.push(`Traffic Flow → ${settings.collectorIp}:${settings.flowListenPort} ipfix`)
|
||||
|
||||
const listed = await listWireGuardInterfaces({ serverId: String(server.id), includePrivateKey: false })
|
||||
const created = listed.interfaces.find((i) => i.name === IFACE_NAME)
|
||||
const publicKey = created?.publicKey ?? ""
|
||||
if (!publicKey) {
|
||||
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
|
||||
}
|
||||
|
||||
db.update(servers).set({
|
||||
mgmtTunnelIp: address,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(servers.id, server.id)).run()
|
||||
|
||||
upsertHostPeer({
|
||||
serverId: server.id,
|
||||
name: server.name || server.host,
|
||||
publicKey,
|
||||
allowedIps: [`${address}/32`],
|
||||
address,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
serverId: server.id,
|
||||
interfaceName: IFACE_NAME,
|
||||
address,
|
||||
publicKey,
|
||||
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host),
|
||||
trafficFlow: true,
|
||||
steps,
|
||||
hostFiles: listTrafficFlowHostFiles(),
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js"
|
||||
import { allocateOverlayAddress } from "./traffic-flow-overlay.js"
|
||||
|
||||
function netflowV5One(): Buffer {
|
||||
const buf = Buffer.alloc(24 + 48)
|
||||
buf.writeUInt16BE(5, 0)
|
||||
buf.writeUInt16BE(1, 2)
|
||||
buf[24] = 10; buf[25] = 1; buf[26] = 1; buf[27] = 8
|
||||
buf[28] = 8; buf[29] = 8; buf[30] = 8; buf[31] = 8
|
||||
buf.writeUInt16BE(1, 24 + 12)
|
||||
buf.writeUInt32BE(10, 24 + 16)
|
||||
buf.writeUInt32BE(1500, 24 + 20)
|
||||
buf.writeUInt16BE(443, 24 + 32)
|
||||
buf.writeUInt16BE(443, 24 + 34)
|
||||
buf.writeUInt8(6, 24 + 38)
|
||||
return buf
|
||||
}
|
||||
|
||||
resetFlowTemplatesForTests()
|
||||
const flows = parseFlowPacket(netflowV5One(), "10.255.254.5")
|
||||
assert.equal(flows.length, 1)
|
||||
assert.equal(flows[0]?.src, "10.1.1.8")
|
||||
assert.equal(flows[0]?.dst, "8.8.8.8")
|
||||
assert.equal(flows[0]?.proto, 6)
|
||||
assert.equal(flows[0]?.bytes, 1500)
|
||||
assert.equal(protoName(6), "TCP")
|
||||
assert.equal(parseFlowPacket(Buffer.from([0, 1]), "1.1.1.1").length, 0)
|
||||
|
||||
const taken = new Set(["10.255.254.2"])
|
||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 1, taken), "10.255.254.3")
|
||||
assert.equal(allocateOverlayAddress("10.255.254.0/24", "10.255.254.1", 2, new Set()), "10.255.254.3")
|
||||
|
||||
import { usablePublicHost } from "./traffic-flow-overlay.js"
|
||||
assert.equal(usablePublicHost("localhost:8000"), "")
|
||||
assert.equal(usablePublicHost("127.0.0.1"), "")
|
||||
assert.equal(usablePublicHost("192.168.1.10"), "")
|
||||
assert.equal(usablePublicHost("mm.example.com:443"), "mm.example.com")
|
||||
assert.equal(usablePublicHost("203.0.113.10"), "203.0.113.10")
|
||||
|
||||
console.log("traffic-flow-parse.test.ts: ok")
|
||||
@@ -0,0 +1,233 @@
|
||||
export interface ParsedFlow {
|
||||
src: string
|
||||
dst: string
|
||||
proto: number
|
||||
srcPort: number
|
||||
dstPort: number
|
||||
bytes: number
|
||||
packets: number
|
||||
inIface: string
|
||||
}
|
||||
|
||||
interface FieldSpec {
|
||||
type: number
|
||||
length: number
|
||||
}
|
||||
|
||||
interface Template {
|
||||
fields: FieldSpec[]
|
||||
}
|
||||
|
||||
const templatesByExporter = new Map<string, Map<number, Template>>()
|
||||
|
||||
function ipv4(buf: Buffer, offset: number): string {
|
||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
|
||||
}
|
||||
|
||||
function readUint(buf: Buffer, offset: number, length: number): number {
|
||||
if (length === 1) return buf.readUInt8(offset)
|
||||
if (length === 2) return buf.readUInt16BE(offset)
|
||||
if (length === 4) return buf.readUInt32BE(offset)
|
||||
if (length === 8) {
|
||||
const big = buf.readBigUInt64BE(offset)
|
||||
const n = Number(big)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
let v = 0
|
||||
for (let i = 0; i < length; i++) v = (v << 8) + buf[offset + i]
|
||||
return v >>> 0
|
||||
}
|
||||
|
||||
function parseNetflowV5(buf: Buffer): ParsedFlow[] {
|
||||
if (buf.length < 24) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
const out: ParsedFlow[] = []
|
||||
let off = 24
|
||||
for (let i = 0; i < count && off + 48 <= buf.length; i++) {
|
||||
out.push({
|
||||
src: ipv4(buf, off),
|
||||
dst: ipv4(buf, off + 4),
|
||||
packets: buf.readUInt32BE(off + 16),
|
||||
bytes: buf.readUInt32BE(off + 20),
|
||||
srcPort: buf.readUInt16BE(off + 32),
|
||||
dstPort: buf.readUInt16BE(off + 34),
|
||||
proto: buf.readUInt8(off + 38),
|
||||
inIface: String(buf.readUInt16BE(off + 12)),
|
||||
})
|
||||
off += 48
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
|
||||
let off = setStart + 4
|
||||
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||
while (off + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(off)
|
||||
const fieldCount = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (setId === 3) {
|
||||
// options template: skip scope count
|
||||
if (off + 2 > setEnd) break
|
||||
off += 2
|
||||
}
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && off + 4 <= setEnd; i++) {
|
||||
const type = buf.readUInt16BE(off)
|
||||
const length = buf.readUInt16BE(off + 2)
|
||||
off += 4
|
||||
if (type & 0x8000) {
|
||||
if (off + 4 > setEnd) break
|
||||
off += 4
|
||||
}
|
||||
fields.push({ type: type & 0x7fff, length })
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
}
|
||||
|
||||
function recordFromFields(fields: FieldSpec[], buf: Buffer, offset: number): { flow: ParsedFlow; next: number } | null {
|
||||
let off = offset
|
||||
let src = ""
|
||||
let dst = ""
|
||||
let proto = 0
|
||||
let srcPort = 0
|
||||
let dstPort = 0
|
||||
let bytes = 0
|
||||
let packets = 0
|
||||
let inIface = ""
|
||||
for (const f of fields) {
|
||||
if (off + f.length > buf.length) return null
|
||||
switch (f.type) {
|
||||
case 8:
|
||||
if (f.length === 4) src = ipv4(buf, off)
|
||||
break
|
||||
case 12:
|
||||
if (f.length === 4) dst = ipv4(buf, off)
|
||||
break
|
||||
case 4:
|
||||
proto = readUint(buf, off, f.length)
|
||||
break
|
||||
case 7:
|
||||
srcPort = readUint(buf, off, f.length)
|
||||
break
|
||||
case 11:
|
||||
dstPort = readUint(buf, off, f.length)
|
||||
break
|
||||
case 1:
|
||||
bytes = readUint(buf, off, f.length)
|
||||
break
|
||||
case 2:
|
||||
packets = readUint(buf, off, f.length)
|
||||
break
|
||||
case 10:
|
||||
inIface = String(readUint(buf, off, f.length))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
off += f.length
|
||||
}
|
||||
if (!src && !dst) return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||
return { flow: { src, dst, proto, srcPort, dstPort, bytes, packets, inIface }, next: off }
|
||||
}
|
||||
|
||||
function parseIpfix(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 16) return []
|
||||
const total = buf.readUInt16BE(2)
|
||||
const end = Math.min(buf.length, total)
|
||||
let off = 16
|
||||
const out: ParsedFlow[] = []
|
||||
while (off + 4 <= end) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > end) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 2 || setId === 3) {
|
||||
parseIpfixTemplates(exporter, buf, off, setEnd, setId)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = templatesByExporter.get(exporter)?.get(setId)
|
||||
if (tpl) {
|
||||
let recOff = off + 4
|
||||
while (recOff + 1 < setEnd) {
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
|
||||
if (buf.length < 20) return []
|
||||
const count = buf.readUInt16BE(2)
|
||||
let off = 20
|
||||
const out: ParsedFlow[] = []
|
||||
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>()
|
||||
for (let s = 0; s < count && off + 4 <= buf.length; s++) {
|
||||
const setId = buf.readUInt16BE(off)
|
||||
const setLen = buf.readUInt16BE(off + 2)
|
||||
if (setLen < 4 || off + setLen > buf.length) break
|
||||
const setEnd = off + setLen
|
||||
if (setId === 0) {
|
||||
let tOff = off + 4
|
||||
while (tOff + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(tOff)
|
||||
const fieldCount = buf.readUInt16BE(tOff + 2)
|
||||
tOff += 4
|
||||
const fields: FieldSpec[] = []
|
||||
for (let i = 0; i < fieldCount && tOff + 4 <= setEnd; i++) {
|
||||
fields.push({ type: buf.readUInt16BE(tOff), length: buf.readUInt16BE(tOff + 2) })
|
||||
tOff += 4
|
||||
}
|
||||
if (templateId >= 256) map.set(templateId, { fields })
|
||||
}
|
||||
templatesByExporter.set(exporter, map)
|
||||
} else if (setId >= 256) {
|
||||
const tpl = map.get(setId)
|
||||
if (tpl) {
|
||||
let recOff = off + 4
|
||||
while (recOff + 1 < setEnd) {
|
||||
const parsed = recordFromFields(tpl.fields, buf, recOff)
|
||||
if (!parsed) break
|
||||
if (parsed.flow.src || parsed.flow.dst) out.push(parsed.flow)
|
||||
if (parsed.next <= recOff) break
|
||||
recOff = parsed.next
|
||||
}
|
||||
}
|
||||
}
|
||||
off = setEnd
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function parseFlowPacket(buf: Buffer, exporterIp: string): ParsedFlow[] {
|
||||
if (buf.length < 2) return []
|
||||
const version = buf.readUInt16BE(0)
|
||||
if (version === 5) return parseNetflowV5(buf)
|
||||
if (version === 9) return parseNetflowV9(buf, exporterIp)
|
||||
if (version === 10) return parseIpfix(buf, exporterIp)
|
||||
return []
|
||||
}
|
||||
|
||||
export function protoName(proto: number): string {
|
||||
switch (proto) {
|
||||
case 1: return "ICMP"
|
||||
case 6: return "TCP"
|
||||
case 17: return "UDP"
|
||||
case 47: return "GRE"
|
||||
case 50: return "ESP"
|
||||
case 89: return "OSPF"
|
||||
default: return String(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetFlowTemplatesForTests() {
|
||||
templatesByExporter.clear()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { trafficFlowSettings } from "../db/schema.js"
|
||||
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function parsePeers(raw: string): FlowHostPeer[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((p): p is FlowHostPeer =>
|
||||
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrafficFlowSettingsRow() {
|
||||
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = nowIso()
|
||||
db.insert(trafficFlowSettings).values({
|
||||
id: 1,
|
||||
enabled: false,
|
||||
collectorIp: "10.255.254.1",
|
||||
flowListenPort: 4739,
|
||||
wgListenPort: 51821,
|
||||
prefix: "10.255.254.0/24",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
export function toTrafficFlowSettingsDto(
|
||||
listener: { bound: boolean; address: string | null },
|
||||
): TrafficFlowSettingsDto {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
collectorIp: row.collectorIp,
|
||||
flowListenPort: row.flowListenPort,
|
||||
wgListenPort: row.wgListenPort,
|
||||
prefix: row.prefix,
|
||||
publicEndpoint: row.publicEndpoint,
|
||||
hostPublicKey: row.hostPublicKey,
|
||||
hasHostPrivateKey: Boolean(row.hostPrivateKey),
|
||||
hubServerId: row.hubServerId ?? null,
|
||||
retentionHours: row.retentionHours,
|
||||
topN: row.topN,
|
||||
lastDatagramAt: row.lastDatagramAt ?? null,
|
||||
lastExporterIp: row.lastExporterIp ?? null,
|
||||
lastError: row.lastError || null,
|
||||
packetsReceived: row.packetsReceived,
|
||||
listenerBound: listener.bound,
|
||||
listenerAddress: listener.address,
|
||||
peers: parsePeers(row.peersJson),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
db.update(trafficFlowSettings).set({
|
||||
enabled: patch.enabled ?? row.enabled,
|
||||
collectorIp: patch.collectorIp ?? row.collectorIp,
|
||||
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
|
||||
wgListenPort: patch.wgListenPort ?? row.wgListenPort,
|
||||
prefix: patch.prefix ?? row.prefix,
|
||||
publicEndpoint: patch.publicEndpoint ?? row.publicEndpoint,
|
||||
hubServerId: patch.hubServerId === undefined ? row.hubServerId : patch.hubServerId,
|
||||
retentionHours: patch.retentionHours ?? row.retentionHours,
|
||||
topN: patch.topN ?? row.topN,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
return getTrafficFlowSettingsRow()
|
||||
}
|
||||
|
||||
export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
if (row.hostPublicKey && row.hostPrivateKey) {
|
||||
return { publicKey: row.hostPublicKey, created: false }
|
||||
}
|
||||
const keys = generateWireGuardKeyPair()
|
||||
db.update(trafficFlowSettings).set({
|
||||
hostPublicKey: keys.publicKey,
|
||||
hostPrivateKey: keys.privateKey,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
return { publicKey: keys.publicKey, created: true }
|
||||
}
|
||||
|
||||
export function upsertHostPeer(peer: FlowHostPeer) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
|
||||
peers.push(peer)
|
||||
db.update(trafficFlowSettings).set({
|
||||
peersJson: JSON.stringify(peers),
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function recordFlowPacket(exporterIp: string) {
|
||||
const row = getTrafficFlowSettingsRow()
|
||||
db.update(trafficFlowSettings).set({
|
||||
lastDatagramAt: nowIso(),
|
||||
lastExporterIp: exporterIp,
|
||||
packetsReceived: row.packetsReceived + 1,
|
||||
lastError: "",
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function recordFlowListenerError(message: string) {
|
||||
db.update(trafficFlowSettings).set({
|
||||
lastError: message,
|
||||
updatedAt: nowIso(),
|
||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||
}
|
||||
|
||||
export function listHostPeers(): FlowHostPeer[] {
|
||||
return parsePeers(getTrafficFlowSettingsRow().peersJson)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
bucketAvg,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
mergeBuiltTraffic,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
shouldIncludeIface,
|
||||
@@ -73,4 +74,52 @@ 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")
|
||||
|
||||
const peerA: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t0, rxBytes: 1_000_000, txBytes: 100_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-a", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 100_000 + 375_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const peerB: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t0, rxBytes: 500_000, txBytes: 50_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", peerPublicKey: "peer-b", sampledAt: t1, rxBytes: 500_000 + 1_875_000, txBytes: 50_000 + 187_500, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const ifaceWg: TrafficSampleLike[] = [
|
||||
{ interfaceName: "wg-server", sampledAt: t0, rxBytes: 10_000_000, txBytes: 2_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "wg-server", sampledAt: t1, rxBytes: 10_000_000 + 7_500_000, txBytes: 2_000_000 + 750_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
const mixed = [...peerA, ...peerB, ...ifaceWg]
|
||||
const rateA = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-a")
|
||||
const rateB = buildTrafficFromSamples(mixed, start, end, "wg-server", "peer-b")
|
||||
const rateIface = buildTrafficFromSamples(mixed, start, end, "wg-server")
|
||||
assert.ok(rateA.rxNow > 0 && rateB.rxNow > 0, "скорость по каждому пиру")
|
||||
assert.notEqual(rateA.rxNow, rateB.rxNow, "два пира одного iface — разный rate")
|
||||
assert.ok(rateIface.rxNow > rateA.rxNow, "iface-level не суммирует пиров")
|
||||
assert.equal(
|
||||
buildTrafficFromSamples(mixed, start, end).rxNow,
|
||||
rateIface.rxNow,
|
||||
"режим сервера игнорирует семплы пиров",
|
||||
)
|
||||
|
||||
console.log("traffic-rate tests ok")
|
||||
|
||||
@@ -4,6 +4,7 @@ export const SERIES_POINTS = 60
|
||||
|
||||
export interface TrafficSampleLike {
|
||||
interfaceName: string
|
||||
peerPublicKey?: string
|
||||
sampledAt: string
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
@@ -94,11 +95,16 @@ function parseIsoMs(iso: string): number {
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
|
||||
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
|
||||
return `${interfaceName}\0${peerPublicKey}`
|
||||
}
|
||||
|
||||
export function buildTrafficFromSamples(
|
||||
rows: TrafficSampleLike[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
onlyInterface?: string | readonly string[],
|
||||
peerPublicKey?: string,
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
@@ -113,13 +119,18 @@ export function buildTrafficFromSamples(
|
||||
}
|
||||
if (rows.length === 0) return empty
|
||||
|
||||
const byIface = new Map<string, TrafficSampleLike[]>()
|
||||
const bySeries = new Map<string, TrafficSampleLike[]>()
|
||||
for (const r of rows) {
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
const peer = r.peerPublicKey ?? ""
|
||||
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
bySeries.set(sampleSeriesKey(r.interfaceName, peer), 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 }>()
|
||||
@@ -128,17 +139,25 @@ export function buildTrafficFromSamples(
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const [name, arr] of byIface) {
|
||||
if (onlyInterface) {
|
||||
if (name !== onlyInterface) continue
|
||||
for (const [key, arr] of bySeries) {
|
||||
const sep = key.indexOf("\0")
|
||||
const name = sep >= 0 ? key.slice(0, sep) : key
|
||||
const peer = sep >= 0 ? key.slice(sep + 1) : ""
|
||||
if (allowList) {
|
||||
if (!allowList.includes(name)) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
if (peerPublicKey === undefined) {
|
||||
if (peer !== "") continue
|
||||
} else if (peer !== peerPublicKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
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 +173,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 +213,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 },
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
peerPublicKey: string
|
||||
peerName: 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[] = []
|
||||
|
||||
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 peerKey = b.peerPublicKey ?? ""
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
|
||||
parts.push(built)
|
||||
const last = [...rows.filter((r) =>
|
||||
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
|
||||
)]
|
||||
.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}:${peerKey || "_iface"}`,
|
||||
bindingId: b.id,
|
||||
userId: user.id,
|
||||
userLogin: user.login,
|
||||
userName: user.name,
|
||||
interfaceName: b.interfaceName,
|
||||
interfaceType: b.interfaceType,
|
||||
peerPublicKey: peerKey,
|
||||
peerName: b.peerName ?? "",
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { generateKeyPairSync } from "node:crypto"
|
||||
|
||||
/** WireGuard Curve25519 keypair as RouterOS/wg-quick base64 (32 bytes). */
|
||||
export function generateWireGuardKeyPair(): { publicKey: string; privateKey: string } {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("x25519")
|
||||
const pubDer = publicKey.export({ type: "spki", format: "der" })
|
||||
const privDer = privateKey.export({ type: "pkcs8", format: "der" })
|
||||
return {
|
||||
publicKey: Buffer.from(pubDer.subarray(-32)).toString("base64"),
|
||||
privateKey: Buffer.from(privDer.subarray(-32)).toString("base64"),
|
||||
}
|
||||
}
|
||||
@@ -211,4 +211,51 @@ export function getEnabledServerById(serverId: string | number): ServerRow | nul
|
||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||
}
|
||||
|
||||
export type CatalogWgPeer = {
|
||||
interfaceName: string
|
||||
publicKey: string
|
||||
name: string
|
||||
comment: string
|
||||
allowedIps: string[]
|
||||
latestHandshake?: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const WG_CATALOG_TIMEOUT_MS = 5_000
|
||||
|
||||
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
|
||||
peers: CatalogWgPeer[]
|
||||
error?: string
|
||||
}> {
|
||||
const row = getEnabledServerById(serverId)
|
||||
if (!row) return { peers: [], error: "Сервер не найден" }
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const peersRaw = await Promise.race([
|
||||
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
|
||||
const mapped = mapPeer(p, idx)
|
||||
const interfaceName = (p.interface ?? "").trim()
|
||||
const publicKey = mapped.publicKey.trim()
|
||||
if (!interfaceName || !publicKey) return []
|
||||
return [{
|
||||
interfaceName,
|
||||
publicKey,
|
||||
name: mapped.name ?? "",
|
||||
comment: mapped.comment ?? "",
|
||||
allowedIps: mapped.allowedIps,
|
||||
latestHandshake: mapped.latestHandshake,
|
||||
disabled: mapped.disabled === true,
|
||||
}]
|
||||
})
|
||||
return { peers }
|
||||
} catch (e) {
|
||||
return { peers: [], error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export { type RosWireGuard, type RosWireGuardPeer }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
|
||||
export function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== "") out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function asRosArray<T>(raw: unknown): T[] {
|
||||
if (Array.isArray(raw)) return raw as T[]
|
||||
if (raw && typeof raw === "object") return [raw as T]
|
||||
return []
|
||||
}
|
||||
|
||||
export function rosRowId(row: Record<string, unknown>): string {
|
||||
return String(row[".id"] ?? row.id ?? "")
|
||||
}
|
||||
|
||||
export async function putWireguardInterface(
|
||||
client: MikrotikClient,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.put("/interface/wireguard", toRosBody(fields))
|
||||
}
|
||||
|
||||
export async function putIpAddress(
|
||||
client: MikrotikClient,
|
||||
address: string,
|
||||
iface: string,
|
||||
): Promise<void> {
|
||||
await client.put("/ip/address", { address, interface: iface })
|
||||
}
|
||||
|
||||
export async function putWireguardPeer(
|
||||
client: MikrotikClient,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.put("/interface/wireguard/peers", toRosBody(fields))
|
||||
}
|
||||
|
||||
export async function patchRosPath(
|
||||
client: MikrotikClient,
|
||||
path: string,
|
||||
fields: Record<string, string | undefined>,
|
||||
): Promise<void> {
|
||||
await client.patch(path, toRosBody(fields))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"
|
||||
import type { FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
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 { fmtRate } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function TrafficFlowsDataGrid({ rows }: { rows: FlowTalkerDto[] }) {
|
||||
const columns = useMemo<ColumnDef<FlowTalkerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "src",
|
||||
accessorKey: "src",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Src</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.src}
|
||||
{row.original.srcPort ? `:${row.original.srcPort}` : ""}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dst</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.dst}
|
||||
{row.original.dstPort ? `:${row.original.dstPort}` : ""}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "proto",
|
||||
accessorKey: "protoName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Proto</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.protoName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "inIface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Iface</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.inIface || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: cn(DATA_GRID_CELL_PAD_LAST) },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row, i) => `${row.serverId}-${row.src}-${row.dst}-${row.proto}-${row.srcPort}-${row.dstPort}-${i}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage="Пока нет IPFIX. Поднимите wg-flow на хосте MM и подключите jump-host одним кликом."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { TrafficFlowsDataGrid }
|
||||
@@ -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,132 @@
|
||||
"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, KeyRoundIcon, 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 = b.interfaceType === "wg" && b.peerPublicKey ? KeyRoundIcon : meta.icon
|
||||
const iconClass = b.interfaceType === "wg" && b.peerPublicKey ? "text-success" : meta.className
|
||||
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", iconClass)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium leading-tight truncate">
|
||||
{b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)
|
||||
? `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||
: 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.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
{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 }
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { FormField } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { applyTrafficFlowOverlay, getTrafficFlowSettings } from "@/shared/api/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import type { TrafficFlowHostFile, TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function FlowOverlaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
backendUrl,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerRead[]
|
||||
backendUrl: string
|
||||
onDone?: (result: TrafficFlowOverlayResult) => void
|
||||
}) {
|
||||
const jumpHosts = useMemo(
|
||||
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
|
||||
[servers],
|
||||
)
|
||||
const [serverId, setServerId] = useState("")
|
||||
const [endpoint, setEndpoint] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [tab, setTab] = useState("wg-quick")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setResult(null)
|
||||
setCopied(false)
|
||||
setTab("wg-quick")
|
||||
setServerId(jumpHosts[0] ? String(jumpHosts[0].id) : "")
|
||||
void getTrafficFlowSettings(backendUrl)
|
||||
.then((s) => setEndpoint(s.publicEndpoint))
|
||||
.catch(() => setEndpoint(""))
|
||||
}, [open, jumpHosts, backendUrl])
|
||||
|
||||
const formats = useMemo((): TrafficFlowHostFile[] => {
|
||||
if (!result) return []
|
||||
return [
|
||||
...result.hostFiles,
|
||||
{
|
||||
id: "peer",
|
||||
label: "[Peer]",
|
||||
filename: "wg-flow-peer.conf",
|
||||
code: result.linuxPeerBlock,
|
||||
},
|
||||
]
|
||||
}, [result])
|
||||
|
||||
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!serverId || !endpoint.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim())
|
||||
setResult(res)
|
||||
setTab(res.hostFiles[0]?.id ?? "peer")
|
||||
toast.success(`wg-flow на ${res.address}`)
|
||||
onDone?.(res)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const code = active?.code ?? ""
|
||||
if (!code) return
|
||||
void navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
toast.success("Скопировано")
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-xl flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Подключить jump-host</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настроит wg-flow и Traffic Flow на MikroTik и сразу выдаст файлы для Linux-хоста Docker MM (wg-quick / compose / firewall).
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<FormField label="Jump-host" required>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none"
|
||||
value={serverId}
|
||||
onChange={(e) => setServerId(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{jumpHosts.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name || s.host} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Публичный IP или DNS хоста Docker MM"
|
||||
required
|
||||
hint="Откуда JH стучится на WG listen (51821). Хост с wg-quick, не контейнер backend."
|
||||
>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="203.0.113.10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormField>
|
||||
{result ? (
|
||||
<div className="flex flex-col gap-4 min-h-0">
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
||||
<AlertDescription>
|
||||
Приватный ключ хоста в SQLite панели — не кладите в git. UDP 4739 публикуйте только на WG-IP, не на 0.0.0.0.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ul className="text-xs text-muted-foreground flex flex-col gap-1">
|
||||
{result.steps.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
{formats.length > 0 && active ? (
|
||||
<div className="flex flex-col gap-3 min-h-0">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(String(v))
|
||||
setCopied(false)
|
||||
}}
|
||||
className="shrink-0 gap-0"
|
||||
>
|
||||
<TabsList className="h-9 w-full">
|
||||
{formats.map((f) => (
|
||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-1.5 text-xs sm:text-sm">
|
||||
{f.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Frame dense className="flex min-h-0 flex-col">
|
||||
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all min-h-[12rem]">
|
||||
{active.code}
|
||||
</pre>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadText(active.filename, active.code)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
Файл
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" />}>Закрыть</SheetClose>
|
||||
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||
{busy ? "Подключение…" : "Подключить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { FlowOverlaySheet }
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
generateTrafficFlowKeys,
|
||||
getTrafficFlowHostFiles,
|
||||
getTrafficFlowSettings,
|
||||
putTrafficFlowSettings,
|
||||
} from "@/shared/api/traffic-flow"
|
||||
import { KeyRoundIcon, DownloadIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
const HOST_STEPS = [
|
||||
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
|
||||
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
|
||||
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
|
||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
|
||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||
]
|
||||
|
||||
function NetflowSettingsPanel({
|
||||
backendUrl,
|
||||
enabled,
|
||||
}: {
|
||||
backendUrl: string
|
||||
enabled: boolean
|
||||
}) {
|
||||
const [settings, setSettings] = useState<TrafficFlowSettingsDto | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
const [formats, setFormats] = useState<CodeExportFormat[]>([])
|
||||
const [collectorIp, setCollectorIp] = useState("10.255.254.1")
|
||||
const [flowPort, setFlowPort] = useState("4739")
|
||||
const [wgPort, setWgPort] = useState("51821")
|
||||
const [prefix, setPrefix] = useState("10.255.254.0/24")
|
||||
const [endpoint, setEndpoint] = useState("")
|
||||
const [retention, setRetention] = useState("24")
|
||||
const [topN, setTopN] = useState("200")
|
||||
const [ingestOn, setIngestOn] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!enabled) return
|
||||
const s = await getTrafficFlowSettings(backendUrl)
|
||||
setSettings(s)
|
||||
setCollectorIp(s.collectorIp)
|
||||
setFlowPort(String(s.flowListenPort))
|
||||
setWgPort(String(s.wgListenPort))
|
||||
setPrefix(s.prefix)
|
||||
setEndpoint(s.publicEndpoint)
|
||||
setRetention(String(s.retentionHours))
|
||||
setTopN(String(s.topN))
|
||||
setIngestOn(s.enabled)
|
||||
}, [backendUrl, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((e: unknown) => {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось загрузить NetFlow")
|
||||
})
|
||||
}, [load])
|
||||
|
||||
async function handleSave() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await putTrafficFlowSettings(backendUrl, {
|
||||
enabled: ingestOn,
|
||||
collectorIp,
|
||||
flowListenPort: Number.parseInt(flowPort, 10) || 4739,
|
||||
wgListenPort: Number.parseInt(wgPort, 10) || 51821,
|
||||
prefix,
|
||||
publicEndpoint: endpoint,
|
||||
retentionHours: Number.parseInt(retention, 10) || 24,
|
||||
topN: Number.parseInt(topN, 10) || 200,
|
||||
})
|
||||
setSettings(res.settings)
|
||||
toast.success("Настройки NetFlow сохранены")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeys() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await generateTrafficFlowKeys(backendUrl)
|
||||
setSettings(res.settings)
|
||||
toast.success(res.created ? "Ключи хоста созданы" : "Ключи уже есть")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось сгенерировать ключи")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await getTrafficFlowHostFiles(backendUrl)
|
||||
setFormats(res.files.map((f) => ({
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
filename: f.filename,
|
||||
code: f.code,
|
||||
})))
|
||||
setExportOpen(true)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось получить файлы")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpsPanel
|
||||
title="Traffic Flow / NetFlow (IPFIX)"
|
||||
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM. Preview: https://reui.io/preview/base/settings-16"
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
{settings?.listenerBound ? (
|
||||
<Badge variant="success">listener {settings.listenerAddress}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">listener выкл</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||
>
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Ключи и UDP 4739</AlertTitle>
|
||||
<AlertDescription>
|
||||
Приватный ключ хранится в SQLite панели, не коммитьте его. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<FormToggle checked={ingestOn} onChange={setIngestOn} />
|
||||
<span className="text-sm">Принимать IPFIX</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||
<Input className="font-mono" value={collectorIp} onChange={(e) => setCollectorIp(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Префикс overlay">
|
||||
<Input className="font-mono" value={prefix} onChange={(e) => setPrefix(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="UDP IPFIX">
|
||||
<Input className="font-mono" value={flowPort} onChange={(e) => setFlowPort(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="WG listen">
|
||||
<Input className="font-mono" value={wgPort} onChange={(e) => setWgPort(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH" required>
|
||||
<Input className="font-mono" value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="203.0.113.10" />
|
||||
</FormField>
|
||||
<FormField label="Public key хоста">
|
||||
<Input className="font-mono text-xs" readOnly value={settings?.hostPublicKey || "— сгенерируйте ключи —"} />
|
||||
</FormField>
|
||||
<FormField label="Хранение (часов)">
|
||||
<Input value={retention} onChange={(e) => setRetention(e.target.value)} inputMode="numeric" />
|
||||
</FormField>
|
||||
<FormField label="Top-N разговоров">
|
||||
<Input value={topN} onChange={(e) => setTopN(e.target.value)} inputMode="numeric" />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last datagram:{" "}
|
||||
{settings?.lastDatagramAt
|
||||
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
|
||||
: "—"}
|
||||
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
|
||||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||||
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||
{HOST_STEPS.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => { void handleSave() }}>
|
||||
Сохранить NetFlow
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleKeys() }}>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Ключи хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => { void handleExport() }}>
|
||||
<DownloadIcon className="size-4" />
|
||||
wg-quick / compose / firewall
|
||||
</Button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
|
||||
<CodeExportSheet
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
title="Файлы для хоста Docker MM"
|
||||
description="wg-quick, фрагмент compose и firewall. Хост, не контейнер backend."
|
||||
formats={formats}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { NetflowSettingsPanel }
|
||||
@@ -0,0 +1,689 @@
|
||||
"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 { IconTile } from "@/components/reui/icon-tile"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item"
|
||||
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 {
|
||||
bindingDiffKey,
|
||||
bindingTitle,
|
||||
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 {
|
||||
CableIcon, ChevronDownIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon,
|
||||
NetworkIcon, PlusIcon, ServerIcon, ShieldIcon, TrashIcon, WrenchIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const IFACE_TILE: 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" },
|
||||
}
|
||||
|
||||
type CatalogPick = {
|
||||
interfaceName: string
|
||||
peerPublicKey: string
|
||||
peerName?: string
|
||||
type: InterfaceType
|
||||
}
|
||||
|
||||
function pickKey(p: CatalogPick): string {
|
||||
return `${p.interfaceName}\0${p.peerPublicKey}`
|
||||
}
|
||||
|
||||
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 [selectedPicks, setSelectedPicks] = useState<CatalogPick[]>([])
|
||||
const [expandedWg, setExpandedWg] = useState<string | null>(null)
|
||||
const [newComment, setNewComment] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm(user ? toForm(user) : emptyForm(servers))
|
||||
setSheetStep(1)
|
||||
setErrors({})
|
||||
setCatalogServerId(servers[0]?.id ?? "")
|
||||
setSelectedPicks([])
|
||||
setExpandedWg(null)
|
||||
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 || selectedPicks.length === 0) return
|
||||
const existing = new Set(form.bindings.map(bindingDiffKey))
|
||||
const next: InterfaceBinding[] = [...form.bindings]
|
||||
for (const pick of selectedPicks) {
|
||||
const key = bindingDiffKey({
|
||||
serverId: catalogSrv.id,
|
||||
interfaceName: pick.interfaceName,
|
||||
peerPublicKey: pick.peerPublicKey,
|
||||
})
|
||||
if (existing.has(key)) continue
|
||||
next.push({
|
||||
id: `pending-${catalogSrv.id}-${pick.interfaceName}-${pick.peerPublicKey || "iface"}`,
|
||||
userId: user?.id ?? "",
|
||||
serverId: catalogSrv.id,
|
||||
serverName: catalogSrv.name,
|
||||
serverSite: catalogSrv.site,
|
||||
serverCountry: catalogSrv.country,
|
||||
interfaceName: pick.interfaceName,
|
||||
interfaceType: pick.type,
|
||||
peerPublicKey: pick.peerPublicKey || undefined,
|
||||
peerName: pick.peerName,
|
||||
comment: newComment.trim(),
|
||||
})
|
||||
}
|
||||
setForm((f) => ({ ...f, bindings: next }))
|
||||
setSelectedPicks([])
|
||||
setNewComment("")
|
||||
}
|
||||
|
||||
const removeBinding = (id: string) => {
|
||||
setForm((f) => ({ ...f, bindings: f.bindings.filter((b) => b.id !== id) }))
|
||||
}
|
||||
|
||||
const togglePick = (pick: CatalogPick) => {
|
||||
setSelectedPicks((prev) => {
|
||||
const key = pickKey(pick)
|
||||
return prev.some((p) => pickKey(p) === key)
|
||||
? prev.filter((p) => pickKey(p) !== key)
|
||||
: [...prev, pick]
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyBoundHere = useMemo(
|
||||
() => new Set(
|
||||
form.bindings
|
||||
.filter((b) => b.serverId === catalogServerId)
|
||||
.map((b) => `${b.interfaceName}\0${b.peerPublicKey ?? ""}`),
|
||||
),
|
||||
[form.bindings, catalogServerId],
|
||||
)
|
||||
|
||||
const selectedPickKeys = useMemo(() => new Set(selectedPicks.map(pickKey)), [selectedPicks])
|
||||
|
||||
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="[email protected]"
|
||||
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">
|
||||
Ethernet и GRE — целиком. WireGuard — только пир (public-key).
|
||||
</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); setSelectedPicks([]); setExpandedWg(null) }}
|
||||
>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name} · {s.site}</option>
|
||||
))}
|
||||
</select>
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="max-h-64 overflow-y-auto p-1.5">
|
||||
{catalog.length === 0 && (
|
||||
<p className="px-2 py-3 text-center text-[11px] text-muted-foreground">Нет интерфейсов в каталоге</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{catalog.map((iface) => {
|
||||
const tile = IFACE_TILE[iface.type]
|
||||
const Icon = tile.icon
|
||||
if (iface.type === "wg") {
|
||||
const open = expandedWg === iface.name
|
||||
const legacyTaken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const legacyMine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||
return (
|
||||
<div key={iface.name} className="flex flex-col gap-0.5">
|
||||
<Item
|
||||
size="xs"
|
||||
variant={open ? "muted" : "default"}
|
||||
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
||||
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT.wg} size="sm">WireGuard</Badge>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{legacyMine ? (
|
||||
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
||||
) : null}
|
||||
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
{open ? (
|
||||
<div className="ml-4 flex flex-col gap-0.5 border-l pl-2">
|
||||
{iface.peersError ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Не удалось загрузить пиры</p>
|
||||
) : null}
|
||||
{(iface.peers ?? []).length === 0 && !iface.peersError ? (
|
||||
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Нет пиров на интерфейсе</p>
|
||||
) : null}
|
||||
{(iface.peers ?? []).map((peer) => {
|
||||
const pick: CatalogPick = {
|
||||
interfaceName: iface.name,
|
||||
peerPublicKey: peer.publicKey,
|
||||
peerName: peer.name,
|
||||
type: "wg",
|
||||
}
|
||||
const taken = Boolean(peer.boundUserId && peer.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(`${iface.name}\0${peer.publicKey}`)
|
||||
const disabled = taken || mine || legacyTaken
|
||||
const selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
key={peer.publicKey}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => togglePick(pick)}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
|
||||
<KeyRoundIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
<span className="min-w-0 truncate">{peer.name || peer.publicKey}</span>
|
||||
{peer.latestHandshake ? (
|
||||
<Badge variant="success-light" size="xs">handshake</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{taken ? (
|
||||
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{peer.boundUserLogin}</span>
|
||||
) : mine ? (
|
||||
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pick: CatalogPick = { interfaceName: iface.name, peerPublicKey: "", type: iface.type }
|
||||
const taken = Boolean(iface.boundUserId && iface.boundUserId !== user?.id)
|
||||
const mine = alreadyBoundHere.has(`${iface.name}\0`)
|
||||
const disabled = taken || mine
|
||||
const selected = selectedPickKeys.has(pickKey(pick))
|
||||
return (
|
||||
<Item
|
||||
key={iface.name}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => togglePick(pick)}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile variant="elevated" className={cn("size-10.5 shrink-0", tile.className)}>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
{iface.running ? <StatusDot status="online" /> : <StatusDot status="offline" />}
|
||||
<span className="min-w-0 truncate">{iface.name}</span>
|
||||
<Badge variant={TYPE_VARIANT[iface.type]} size="sm">
|
||||
{IFACE_TYPE_LABEL[iface.type]}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{taken ? (
|
||||
<span className="max-w-28 truncate text-[10px] text-muted-foreground">{iface.boundUserLogin}</span>
|
||||
) : mine ? (
|
||||
<span className="text-[10px] text-muted-foreground">уже привязан</span>
|
||||
) : selected ? (
|
||||
<Badge variant="secondary" size="xs">Выбран</Badge>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
/>
|
||||
<Button size="sm" disabled={selectedPicks.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">{bindingTitle(b)}</span>
|
||||
<Badge variant={TYPE_VARIANT[b.interfaceType]} size="sm">{IFACE_TYPE_LABEL[b.interfaceType]}</Badge>
|
||||
{b.interfaceType === "wg" && !(b.peerPublicKey ?? "") ? (
|
||||
<Badge variant="warning-light" size="sm">весь интерфейс</Badge>
|
||||
) : null}
|
||||
<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,8 +5,12 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# IPFIX: только на WG-адресе хоста после `wg-quick up wg-flow`, не 0.0.0.0
|
||||
# - "10.255.254.1:4739:4739/udp"
|
||||
environment:
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
|
||||
# Внутри контейнера слушаем все интерфейсы; на хосте UDP 4739 публикуется только на WG-IP
|
||||
FLOW_LISTEN_HOST: "0.0.0.0"
|
||||
volumes:
|
||||
- /opt/mmapp/data:/app/data
|
||||
labels:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
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
|
||||
peerPublicKey?: string
|
||||
peerName?: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
export interface CatalogPeer {
|
||||
publicKey: string
|
||||
name: string
|
||||
comment: string
|
||||
allowedIps: string[]
|
||||
latestHandshake?: string
|
||||
boundUserId: string | null
|
||||
boundUserLogin: string | null
|
||||
}
|
||||
|
||||
export interface CatalogIface {
|
||||
name: string
|
||||
type: InterfaceType
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
boundUserId: string | null
|
||||
boundUserLogin: string | null
|
||||
peers?: CatalogPeer[]
|
||||
peersError?: 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 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: "[email protected]" },
|
||||
{ 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: "[email protected]" },
|
||||
{ name: "gre-datacenter", type: "gre", running: true, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||
{ name: "gre-retail-01", type: "gre", running: false, disabled: false, boundUserId: "u4", boundUserLogin: "[email protected]" },
|
||||
{
|
||||
name: "wg-msk-spb", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||
peers: [
|
||||
{ publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak", comment: "", allowedIps: ["10.8.0.2/32"], boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||
{ publicKey: "mockPeerKeyBBBB0123456789", name: "laptop-ak", comment: "", allowedIps: ["10.8.0.3/32"], boundUserId: null, boundUserLogin: null, latestHandshake: "12s" },
|
||||
],
|
||||
},
|
||||
],
|
||||
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: "[email protected]" },
|
||||
{ name: "gre-warehouse", type: "gre", running: false, disabled: false, boundUserId: "u1", boundUserLogin: "[email protected]" },
|
||||
{ name: "gre-spb-branch", type: "gre", running: true, disabled: false, boundUserId: "u2", boundUserLogin: "[email protected]" },
|
||||
{
|
||||
name: "wg-lab", type: "wg", running: true, disabled: false, boundUserId: null, boundUserLogin: null,
|
||||
peers: [
|
||||
{ publicKey: "mockPeerKeyLABB0123456789", name: "lab-peer", comment: "", allowedIps: ["10.9.0.2/32"], boundUserId: null, boundUserLogin: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function bindingDiffKey(b: Pick<InterfaceBinding, "serverId" | "interfaceName" | "peerPublicKey">): string {
|
||||
return `${b.serverId}::${b.interfaceName}::${b.peerPublicKey ?? ""}`
|
||||
}
|
||||
|
||||
export function bindingTitle(b: Pick<InterfaceBinding, "interfaceName" | "interfaceType" | "peerName" | "peerPublicKey">): string {
|
||||
if (b.interfaceType === "wg" && (b.peerName || b.peerPublicKey)) {
|
||||
return `${b.peerName || "peer"} · ${b.interfaceName}`
|
||||
}
|
||||
return b.interfaceName
|
||||
}
|
||||
|
||||
function bind(
|
||||
id: string,
|
||||
userId: string,
|
||||
serverId: string,
|
||||
interfaceName: string,
|
||||
interfaceType: InterfaceType,
|
||||
comment: string,
|
||||
peer?: { publicKey: string; name: 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,
|
||||
peerPublicKey: peer?.publicKey,
|
||||
peerName: peer?.name,
|
||||
comment,
|
||||
}
|
||||
}
|
||||
|
||||
export const INIT_USERS: AppUser[] = [
|
||||
{
|
||||
id: "u1", name: "Александр Коротаев", login: "[email protected]", email: "[email protected]",
|
||||
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", { publicKey: "mockPeerKeyAAAA0123456789", name: "phone-ak" }),
|
||||
bind("b5", "u1", "srv7", "gre-office-msk", "gre", "Офис LAB"),
|
||||
bind("b6", "u1", "srv7", "gre-warehouse", "gre", "Склад"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "u2", name: "Дмитрий Фёдоров", login: "[email protected]", email: "[email protected]",
|
||||
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: "[email protected]", email: "[email protected]",
|
||||
role: "viewer", last: "вчера", avatar: "МС", active: true,
|
||||
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
|
||||
bindings: [],
|
||||
},
|
||||
{
|
||||
id: "u4", name: "Игорь Петров", login: "[email protected]", email: "[email protected]",
|
||||
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) => {
|
||||
if (iface.type === "wg") {
|
||||
const peers = (iface.peers ?? []).map((peer) => {
|
||||
const owner = users.find((u) =>
|
||||
u.bindings.some((b) =>
|
||||
b.serverId === serverId
|
||||
&& b.interfaceName === iface.name
|
||||
&& (b.peerPublicKey ?? "") === peer.publicKey,
|
||||
),
|
||||
)
|
||||
if (!owner) return { ...peer, boundUserId: null, boundUserLogin: null }
|
||||
return { ...peer, boundUserId: owner.id, boundUserLogin: owner.email || owner.login }
|
||||
})
|
||||
const legacy = users.find((u) =>
|
||||
u.bindings.some((b) =>
|
||||
b.serverId === serverId
|
||||
&& b.interfaceName === iface.name
|
||||
&& !(b.peerPublicKey ?? ""),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...iface,
|
||||
boundUserId: legacy?.id ?? null,
|
||||
boundUserLogin: legacy ? (legacy.email || legacy.login) : null,
|
||||
peers,
|
||||
}
|
||||
}
|
||||
const owner = users.find((u) =>
|
||||
u.bindings.some((b) => b.serverId === serverId && b.interfaceName === iface.name && !(b.peerPublicKey ?? "")),
|
||||
)
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@
|
||||
"./wireguard": {
|
||||
"types": "./dist/wireguard.d.ts",
|
||||
"default": "./dist/wireguard.js"
|
||||
},
|
||||
"./users": {
|
||||
"types": "./dist/users.d.ts",
|
||||
"default": "./dist/users.js"
|
||||
},
|
||||
"./traffic-flow": {
|
||||
"types": "./dist/traffic-flow.d.ts",
|
||||
"default": "./dist/traffic-flow.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,3 +4,5 @@ export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
export * from "./backups.js"
|
||||
export * from "./wireguard.js"
|
||||
export * from "./users.js"
|
||||
export * from "./traffic-flow.js"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const flowHostPeerSchema = z.object({
|
||||
serverId: z.number().int().positive(),
|
||||
name: z.string(),
|
||||
publicKey: z.string().min(1),
|
||||
allowedIps: z.array(z.string().min(1)).min(1),
|
||||
address: z.string().min(1),
|
||||
})
|
||||
|
||||
export const trafficFlowSettingsDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
collectorIp: z.string().min(1),
|
||||
flowListenPort: z.number().int().positive(),
|
||||
wgListenPort: z.number().int().positive(),
|
||||
prefix: z.string().min(1),
|
||||
publicEndpoint: z.string(),
|
||||
hostPublicKey: z.string(),
|
||||
hasHostPrivateKey: z.boolean(),
|
||||
hubServerId: z.number().int().positive().nullable(),
|
||||
retentionHours: z.number().int().positive(),
|
||||
topN: z.number().int().positive(),
|
||||
lastDatagramAt: z.string().nullable(),
|
||||
lastExporterIp: z.string().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
packetsReceived: z.number().int().nonnegative(),
|
||||
listenerBound: z.boolean(),
|
||||
listenerAddress: z.string().nullable(),
|
||||
peers: z.array(flowHostPeerSchema),
|
||||
})
|
||||
|
||||
export const trafficFlowSettingsPatchSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
collectorIp: z.string().min(1).optional(),
|
||||
flowListenPort: z.number().int().positive().optional(),
|
||||
wgListenPort: z.number().int().positive().optional(),
|
||||
prefix: z.string().min(1).optional(),
|
||||
publicEndpoint: z.string().optional(),
|
||||
hubServerId: z.number().int().positive().nullable().optional(),
|
||||
retentionHours: z.number().int().positive().optional(),
|
||||
topN: z.number().int().positive().max(1000).optional(),
|
||||
})
|
||||
|
||||
export const trafficFlowOverlayRequestSchema = z.object({
|
||||
serverId: z.union([z.string(), z.number()]),
|
||||
publicEndpoint: z.string().optional(),
|
||||
})
|
||||
|
||||
export const trafficFlowHostFileSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
filename: z.string(),
|
||||
code: z.string(),
|
||||
})
|
||||
|
||||
export const trafficFlowOverlayResultSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
serverId: z.number().int(),
|
||||
interfaceName: z.string(),
|
||||
address: z.string(),
|
||||
publicKey: z.string(),
|
||||
linuxPeerBlock: z.string(),
|
||||
trafficFlow: z.boolean(),
|
||||
steps: z.array(z.string()),
|
||||
hostFiles: z.array(trafficFlowHostFileSchema),
|
||||
})
|
||||
|
||||
export const flowTalkerDtoSchema = z.object({
|
||||
serverId: z.string(),
|
||||
serverName: z.string(),
|
||||
src: z.string(),
|
||||
dst: z.string(),
|
||||
proto: z.number().int(),
|
||||
protoName: z.string(),
|
||||
srcPort: z.number().int(),
|
||||
dstPort: z.number().int(),
|
||||
bytes: z.number().nonnegative(),
|
||||
packets: z.number().nonnegative(),
|
||||
bps: z.number().nonnegative(),
|
||||
inIface: z.string(),
|
||||
})
|
||||
|
||||
export const flowStatsDtoSchema = z.object({
|
||||
exportersOnline: z.number().int().nonnegative(),
|
||||
bytesPerMin: z.number().nonnegative(),
|
||||
uniqueSrc: z.number().int().nonnegative(),
|
||||
uniqueDst: z.number().int().nonnegative(),
|
||||
topProto: z.string(),
|
||||
talkers: z.array(flowTalkerDtoSchema),
|
||||
})
|
||||
|
||||
export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
|
||||
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
|
||||
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
|
||||
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
|
||||
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
|
||||
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
|
||||
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
|
||||
@@ -0,0 +1,119 @@
|
||||
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,
|
||||
peerPublicKey: z.string().default(""),
|
||||
peerName: z.string().default(""),
|
||||
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(),
|
||||
peerPublicKey: z.string().optional(),
|
||||
peerName: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
})
|
||||
|
||||
export const interfaceCatalogQuerySchema = z.object({
|
||||
serverId: z.coerce.number().int().positive(),
|
||||
})
|
||||
|
||||
export const catalogPeerSchema = z.object({
|
||||
publicKey: z.string(),
|
||||
name: z.string(),
|
||||
comment: z.string(),
|
||||
allowedIps: z.array(z.string()),
|
||||
latestHandshake: z.string().optional(),
|
||||
boundUserId: z.string().nullable(),
|
||||
boundUserLogin: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const catalogInterfaceSchema = z.object({
|
||||
name: z.string(),
|
||||
type: interfaceTypeSchema,
|
||||
running: z.boolean(),
|
||||
disabled: z.boolean(),
|
||||
boundUserId: z.string().nullable(),
|
||||
boundUserLogin: z.string().nullable(),
|
||||
peers: z.array(catalogPeerSchema).optional(),
|
||||
peersError: z.string().optional(),
|
||||
})
|
||||
|
||||
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 CatalogPeer = z.infer<typeof catalogPeerSchema>
|
||||
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
FlowStatsDto,
|
||||
TrafficFlowHostFile,
|
||||
TrafficFlowOverlayResult,
|
||||
TrafficFlowSettingsDto,
|
||||
TrafficFlowSettingsPatch,
|
||||
} from "@mmapp/contracts/traffic-flow"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
export type { TrafficFlowHostFile }
|
||||
|
||||
export async function getTrafficFlowSettings(baseUrl: string): Promise<TrafficFlowSettingsDto> {
|
||||
return requestJson<TrafficFlowSettingsDto>(baseUrl, "/api/traffic/flow/settings")
|
||||
}
|
||||
|
||||
export async function putTrafficFlowSettings(
|
||||
baseUrl: string,
|
||||
patch: TrafficFlowSettingsPatch,
|
||||
): Promise<{ ok: boolean; settings: TrafficFlowSettingsDto }> {
|
||||
return requestJson(baseUrl, "/api/traffic/flow/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateTrafficFlowKeys(baseUrl: string): Promise<{
|
||||
ok: boolean
|
||||
created: boolean
|
||||
publicKey: string
|
||||
settings: TrafficFlowSettingsDto
|
||||
}> {
|
||||
return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" })
|
||||
}
|
||||
|
||||
export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> {
|
||||
return requestJson(baseUrl, "/api/traffic/flow/host-files")
|
||||
}
|
||||
|
||||
export async function applyTrafficFlowOverlay(
|
||||
baseUrl: string,
|
||||
serverId: string | number,
|
||||
publicEndpoint?: string,
|
||||
): Promise<TrafficFlowOverlayResult> {
|
||||
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId, publicEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
|
||||
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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,
|
||||
peerPublicKey: b.peerPublicKey || undefined,
|
||||
peerName: b.peerName || undefined,
|
||||
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[]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user