1871 lines
92 KiB
TypeScript
1871 lines
92 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import Link from "next/link"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import {
|
||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||
} from "@/components/ui/card"
|
||
import { Button, buttonVariants } from "@/components/ui/button"
|
||
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 { 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,
|
||
} from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
// ─── types ────────────────────────────────────────────────────────────────────
|
||
|
||
type Role = "admin" | "operator" | "viewer"
|
||
type PermLevel = "none" | "read" | "write"
|
||
|
||
interface SectionPerm { section: string; level: PermLevel }
|
||
interface ServerPerm { serverId: string; level: PermLevel }
|
||
|
||
interface User {
|
||
id: string; name: string; login: string; email: string
|
||
role: Role; last: string; avatar: string; active: boolean
|
||
sections: SectionPerm[]; servers: ServerPerm[]
|
||
subUsers: SubUser[]
|
||
}
|
||
|
||
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
|
||
|
||
// Подчинённый пользователь — учётка для подключения устройства к JH через GRE
|
||
interface SubUser {
|
||
id: string
|
||
login: string
|
||
password: string
|
||
description: string // "офисный роутер", "склад", и т.п.
|
||
jhServerIds: string[] // на каких JH зарегистрирована учётка
|
||
clientIp: string // назначенный IP внутри туннеля
|
||
active: boolean
|
||
lastSeen: string | null
|
||
}
|
||
|
||
// ─── section definitions ──────────────────────────────────────────────────────
|
||
|
||
const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [
|
||
{ group: "Обзор", icon: <LayoutDashboardIcon className="size-3" />, items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
|
||
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
|
||
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
|
||
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
|
||
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Настройки"] },
|
||
]
|
||
|
||
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
|
||
|
||
// ─── default permissions by role ──────────────────────────────────────────────
|
||
|
||
function defaultSections(role: Role): SectionPerm[] {
|
||
return ALL_SECTIONS.map(section => {
|
||
let level: PermLevel = "none"
|
||
if (role === "admin") level = "write"
|
||
else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read"
|
||
else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read"
|
||
return { section, level }
|
||
})
|
||
}
|
||
|
||
function defaultServers(role: Role): ServerPerm[] {
|
||
return servers.map(s => ({
|
||
serverId: s.id,
|
||
level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel,
|
||
}))
|
||
}
|
||
|
||
// ─── initial data ──────────────────────────────────────────────────────────────
|
||
|
||
const INIT_USERS: User[] = [
|
||
{
|
||
id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io",
|
||
role: "admin", last: "сейчас", avatar: "АК", active: true,
|
||
sections: defaultSections("admin"), servers: defaultServers("admin"),
|
||
subUsers: [
|
||
{ id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" },
|
||
{ id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" },
|
||
{ id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" },
|
||
],
|
||
},
|
||
{
|
||
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io",
|
||
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
|
||
sections: defaultSections("operator"), servers: defaultServers("operator"),
|
||
subUsers: [
|
||
{ id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" },
|
||
],
|
||
},
|
||
{
|
||
id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io",
|
||
role: "viewer", last: "вчера", avatar: "МС", active: true,
|
||
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
|
||
subUsers: [],
|
||
},
|
||
{
|
||
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io",
|
||
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
|
||
sections: defaultSections("operator"), servers: defaultServers("operator"),
|
||
subUsers: [
|
||
{ id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null },
|
||
],
|
||
},
|
||
]
|
||
|
||
const INIT_API_KEYS: ApiKey[] = [
|
||
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
|
||
{ id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] },
|
||
{ 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-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||
}
|
||
|
||
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||
type NavSection = typeof SECTIONS_NAV[number]
|
||
|
||
interface CollectorSettingsDto {
|
||
enabled: boolean
|
||
intervalSec: number
|
||
probeIntervalSec?: number
|
||
speedIntervalSec?: number
|
||
retentionDays: number
|
||
lastCollectedAt: string | null
|
||
lastDurationMs: number | null
|
||
lastError: string | null
|
||
collectorRunning?: boolean
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||
...init,
|
||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||
})
|
||
if (!res.ok) {
|
||
let message = res.statusText
|
||
try {
|
||
const err = await res.json() as { error?: string }
|
||
message = err.error ?? message
|
||
} catch {
|
||
const text = await res.text().catch(() => "")
|
||
if (text) message = text
|
||
}
|
||
throw new Error(message || "Ошибка запроса")
|
||
}
|
||
return res.json() as Promise<T>
|
||
}
|
||
}
|
||
|
||
// ─── small components ─────────────────────────────────────────────────────────
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button type="button" onClick={() => onChange(!checked)}
|
||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
||
checked ? "bg-primary" : "bg-input")}>
|
||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0")} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium">{label}</p>
|
||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||
</div>
|
||
<div className="shrink-0">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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-emerald-600 text-white dark:bg-emerald-500"
|
||
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500"
|
||
: "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">
|
||
|
||
<Field label="Полное имя" error={errors.name}>
|
||
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
||
placeholder="Иван Иванов" className="h-9" />
|
||
</Field>
|
||
|
||
<Field label="Логин" error={errors.login}>
|
||
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
||
placeholder="i.ivanov" className="h-9 font-mono" />
|
||
</Field>
|
||
|
||
<Field label="Email" error={errors.email}>
|
||
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
||
placeholder="i.ivanov@company.io" type="email" className="h-9" />
|
||
</Field>
|
||
|
||
<Separator />
|
||
|
||
<Field 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>
|
||
</Field>
|
||
|
||
<Separator />
|
||
|
||
<Field label="Статус учётной записи">
|
||
<div className="flex items-center gap-3">
|
||
<Toggle 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>
|
||
</Field>
|
||
</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>
|
||
|
||
{/* table header */}
|
||
{form.subUsers.length > 0 && (
|
||
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||
<span>Логин / описание</span>
|
||
<span>Пароль</span>
|
||
<span>JH-серверы</span>
|
||
<span>IP-клиента</span>
|
||
<span />
|
||
<span />
|
||
</div>
|
||
)}
|
||
|
||
{/* rows */}
|
||
<div className="divide-y divide-border/60">
|
||
{form.subUsers.length === 0 && !addSubOpen && (
|
||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||
<CableIcon className="size-6 opacity-20" />
|
||
<p className="text-sm">Нет GRE-клиентов</p>
|
||
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
|
||
</div>
|
||
)}
|
||
|
||
{form.subUsers.map(su => {
|
||
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
|
||
const revealed = revealedIds.has(su.id)
|
||
return (
|
||
<div key={su.id}
|
||
className={cn(
|
||
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||
!su.active && "opacity-50",
|
||
)}
|
||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||
|
||
{/* login + description */}
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||
{su.description && (
|
||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||
)}
|
||
{su.lastSeen && (
|
||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* password */}
|
||
<div className="flex items-center gap-1 min-w-0">
|
||
<span className="font-mono text-[11px] truncate flex-1">
|
||
{revealed ? su.password : "••••••••••••"}
|
||
</span>
|
||
<button onClick={() => toggleReveal(su.id)}
|
||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||
{revealed
|
||
? <EyeOffIcon className="size-3" />
|
||
: <EyeIcon className="size-3" />}
|
||
</button>
|
||
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||
<CopyIcon className="size-3" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* JH servers */}
|
||
<div className="flex flex-wrap gap-1 min-w-0">
|
||
{jhs.length === 0
|
||
? <span className="text-[11px] text-muted-foreground/40">—</span>
|
||
: jhs.map(jh => (
|
||
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
|
||
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
|
||
rounded px-1 py-0.5">
|
||
<Flag code={jh.country} size={10} />
|
||
{jh.name.split("-").slice(-1)[0]}
|
||
</span>
|
||
))
|
||
}
|
||
</div>
|
||
|
||
{/* client IP */}
|
||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||
{su.clientIp || "—"}
|
||
</span>
|
||
|
||
{/* active toggle */}
|
||
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||
|
||
{/* delete */}
|
||
<button onClick={() => removeSubUser(su.id)}
|
||
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
|
||
<TrashIcon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* 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>
|
||
)
|
||
}
|
||
|
||
// ─── helper ───────────────────────────────────────────────────────────────────
|
||
|
||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-xs font-medium">{label}</label>
|
||
{children}
|
||
{error && (
|
||
<p className="text-xs text-destructive flex items-center gap-1">
|
||
<AlertCircleIcon className="size-3" />{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||
|
||
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
|
||
<TrashIcon className="size-4 text-destructive" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-semibold">Удалить пользователя?</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Это действие нельзя отменить. Пользователь потеряет доступ немедленно.
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button>
|
||
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||
|
||
export default function SettingsPage() {
|
||
const [section, setSection] = useState<NavSection>("Общие")
|
||
const [saved, setSaved] = useState(false)
|
||
const [copied, setCopied] = useState<string | null>(null)
|
||
|
||
// data source
|
||
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource()
|
||
const evo = useEvoBGP()
|
||
const [evoBaseDraft, setEvoBaseDraft] = useState("")
|
||
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
|
||
const [evoKeyDraft, setEvoKeyDraft] = useState("")
|
||
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
|
||
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
|
||
const [urlDraft, setUrlDraft] = useState(backendUrl)
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
||
const [showEvoKey, setShowEvoKey] = useState(false)
|
||
|
||
// collectors
|
||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
|
||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
|
||
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
|
||
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
|
||
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
|
||
const [collectorError, setCollectorError] = useState<string | null>(null)
|
||
|
||
// general
|
||
const [lang, setLang] = useState("ru")
|
||
const [theme, setTheme] = useState("system")
|
||
const [timezone, setTimezone] = useState("Europe/Moscow")
|
||
const [refreshSec, setRefreshSec] = useState("30")
|
||
const [probeTimeout, setProbeTimeout] = useState("5")
|
||
|
||
// notifications
|
||
const [notifEmail, setNotifEmail] = useState(true)
|
||
const [notifSlack, setNotifSlack] = useState(false)
|
||
const [notifWh, setNotifWh] = useState(true)
|
||
const [notifDegr, setNotifDegr] = useState(true)
|
||
const [notifOffline, setNotifOffline] = useState(true)
|
||
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)
|
||
|
||
// security
|
||
const [mfa, setMfa] = useState(true)
|
||
const [sessMin, setSessMin] = useState("480")
|
||
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
|
||
const [auditLog, setAuditLog] = useState(true)
|
||
|
||
const handleCopy = (text: string) => {
|
||
navigator.clipboard.writeText(text).catch(() => {})
|
||
setCopied(text); setTimeout(() => setCopied(null), 1500)
|
||
}
|
||
|
||
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)
|
||
|
||
const loadCollectors = useCallback(async () => {
|
||
if (mode !== "live" || backendStatus !== true) return
|
||
setCollectorError(null)
|
||
try {
|
||
const [traffic, uptime] = await Promise.all([
|
||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
|
||
])
|
||
setTrafficCollector(traffic)
|
||
setUptimeCollector(uptime)
|
||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
|
||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||
} catch (e) {
|
||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
|
||
}
|
||
}, [apiFetch, backendStatus, mode])
|
||
|
||
useEffect(() => {
|
||
if (section !== "Сбор данных") return
|
||
queueMicrotask(() => { void loadCollectors() })
|
||
}, [section, loadCollectors])
|
||
|
||
useEffect(() => {
|
||
if (section !== "EvoBGP") return
|
||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||
}, [section, mode, backendStatus, evo.loadSettings])
|
||
|
||
useEffect(() => {
|
||
if (section !== "EvoBGP" || !evo.settingsLoaded) return
|
||
setEvoBaseDraft(evo.baseUrl)
|
||
setEvoEnabledDraft(evo.enabled)
|
||
setEvoKeyDraft("")
|
||
setEvoSaveErr(null)
|
||
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
|
||
|
||
const handleSave = useCallback(async () => {
|
||
if (section === "EvoBGP") {
|
||
if (mode !== "live" || backendStatus !== true) {
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
return
|
||
}
|
||
setEvoSaveBusy(true)
|
||
setEvoSaveErr(null)
|
||
try {
|
||
const patch: EvoBgpSavePayload = {
|
||
baseUrl: evoBaseDraft.trim(),
|
||
enabled: evoEnabledDraft,
|
||
}
|
||
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
|
||
await evo.saveSettings(patch)
|
||
setEvoKeyDraft("")
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
} catch (e) {
|
||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||
} finally {
|
||
setEvoSaveBusy(false)
|
||
}
|
||
return
|
||
}
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
}, [
|
||
section,
|
||
mode,
|
||
backendStatus,
|
||
evoBaseDraft,
|
||
evoEnabledDraft,
|
||
evoKeyDraft,
|
||
evo.saveSettings,
|
||
])
|
||
|
||
const renderContent = () => {
|
||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||
|
||
// ── Общие ──
|
||
if (section === "Общие") return (
|
||
<div className="space-y-4">
|
||
|
||
{/* ── Источник данных ── */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Источник данных</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
|
||
{/* mode toggle */}
|
||
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
||
<button key={v} onClick={() => setMode(v)}
|
||
className={cn(
|
||
"px-3 text-xs transition-colors border-input",
|
||
i === 0 && "border-r",
|
||
mode === v
|
||
? v === "live"
|
||
? "bg-emerald-600 text-white dark:bg-emerald-500"
|
||
: "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* backend URL — visible in both modes so user can configure before switching */}
|
||
<SettingRow label="URL бекенда" description="Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)">
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="w-56 h-8 text-sm font-mono"
|
||
value={urlDraft}
|
||
onChange={e => setUrlDraft(e.target.value)}
|
||
onBlur={() => { setBackendUrl(urlDraft.replace(/\/$/, "")) }}
|
||
placeholder="http://localhost:8000"
|
||
/>
|
||
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
|
||
onClick={() => { setBackendUrl(urlDraft.replace(/\/$/, "")); checkBackend() }}
|
||
title="Проверить соединение">
|
||
<RefreshCwIcon className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* connection status */}
|
||
<SettingRow label="Статус бекенда">
|
||
<div className="flex items-center gap-2">
|
||
{backendStatus === undefined && (
|
||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||
Не проверялось
|
||
</span>
|
||
)}
|
||
{backendStatus === true && (
|
||
<span className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||
<CheckIcon className="size-3.5" />Доступен
|
||
</span>
|
||
)}
|
||
{backendStatus === false && (
|
||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||
<XIcon className="size-3.5" />Недоступен
|
||
</span>
|
||
)}
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground"
|
||
onClick={checkBackend}>
|
||
Проверить
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
|
||
{/* hint when live but backend unreachable */}
|
||
{mode === "live" && backendStatus === false && (
|
||
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400">
|
||
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||
<span>Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: <code className="font-mono bg-muted px-1 rounded">cd backend && npm run dev</code></span>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── Основные настройки ── */}
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
<SettingRow label="Язык интерфейса">
|
||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
value={lang} onChange={e => setLang(e.target.value)}>
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="Тема оформления">
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
{[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => (
|
||
<button key={v} onClick={() => setTheme(v)}
|
||
className={cn("px-3 text-xs transition-colors border-r last:border-r-0 border-input",
|
||
theme === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label="Часовой пояс">
|
||
<select className="text-sm bg-background text-foreground border border-input rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
value={timezone} onChange={e => setTimezone(e.target.value)}>
|
||
{["Europe/Moscow","Europe/Berlin","Europe/Amsterdam","Asia/Singapore","UTC"].map(z => (
|
||
<option key={z} value={z}>{z}</option>
|
||
))}
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label="Интервал автообновления (сек)"
|
||
description="Как часто страницы обновляют данные">
|
||
<Input className="w-20 h-8 text-sm" value={refreshSec} onChange={e => setRefreshSec(e.target.value)} />
|
||
</SettingRow>
|
||
<SettingRow label="Таймаут диагностики (сек)"
|
||
description="Максимальное время ожидания при probe-тестах">
|
||
<Input className="w-20 h-8 text-sm" value={probeTimeout} onChange={e => setProbeTimeout(e.target.value)} />
|
||
</SettingRow>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card id="route-ai" className="scroll-mt-4">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Route AI</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
|
||
веса настраиваются в инструменте, не через отдельный API.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4 px-5 pb-5">
|
||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
|
||
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Гистерезис</dt>
|
||
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
|
||
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
|
||
<dt className="text-muted-foreground">Интервал зондирования</dt>
|
||
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
|
||
<dt className="text-muted-foreground">Автоприменение</dt>
|
||
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
|
||
</div>
|
||
</dl>
|
||
<Link
|
||
href="/route-optimizer"
|
||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
|
||
>
|
||
Открыть оптимизатор маршрутов
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
)
|
||
|
||
// ── Сбор данных ──
|
||
if (section === "Сбор данных") return (
|
||
<div className="space-y-4">
|
||
{(mode !== "live" || backendStatus !== true) && (
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-4">
|
||
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{mode === "live" && backendStatus === true && (
|
||
<>
|
||
{collectorError && (
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-4">
|
||
<p className="text-xs text-destructive">{collectorError}</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Сбор трафика</CardTitle>
|
||
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 px-5 pb-5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm">Состояние</span>
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||
disabled={collectorBusy === "traffic"}
|
||
onClick={async () => {
|
||
setCollectorBusy("traffic")
|
||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Вкл</button>
|
||
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||
disabled={collectorBusy === "traffic"}
|
||
onClick={async () => {
|
||
setCollectorBusy("traffic")
|
||
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Выкл</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
|
||
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||
setCollectorBusy("traffic")
|
||
try {
|
||
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
|
||
await loadCollectors()
|
||
} finally { setCollectorBusy(null) }
|
||
}}>Сохранить</Button>
|
||
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
|
||
setCollectorBusy("traffic")
|
||
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Собрать сейчас</Button>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
|
||
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Сбор uptime</CardTitle>
|
||
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 px-5 pb-5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-sm">Состояние</span>
|
||
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
|
||
disabled={collectorBusy === "uptime"}
|
||
onClick={async () => {
|
||
setCollectorBusy("uptime")
|
||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Вкл</button>
|
||
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
|
||
disabled={collectorBusy === "uptime"}
|
||
onClick={async () => {
|
||
setCollectorBusy("uptime")
|
||
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Выкл</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
|
||
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3">
|
||
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||
setCollectorBusy("uptime")
|
||
try {
|
||
await apiFetch("/api/uptime/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
|
||
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
|
||
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
|
||
}),
|
||
})
|
||
await loadCollectors()
|
||
} finally { setCollectorBusy(null) }
|
||
}}>Сохранить</Button>
|
||
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
|
||
setCollectorBusy("uptime")
|
||
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
|
||
finally { setCollectorBusy(null) }
|
||
}}>Собрать сейчас</Button>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
|
||
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
|
||
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
// ── EvoBGP ──
|
||
if (section === "EvoBGP") return (
|
||
<div className="space-y-4">
|
||
{(mode !== "live" || backendStatus !== true) && (
|
||
<Card>
|
||
<CardContent className="pt-4 pb-4 px-4">
|
||
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">EvoBGP API</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Control plane EvoBGP: Bearer-ключ и роль viewer+ — см.{" "}
|
||
<a
|
||
className="underline underline-offset-2"
|
||
href="https://git.shts.su/denozord/EvoBGP/src/branch/main/docs/access.md"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
access.md
|
||
</a>
|
||
. URL и ключ хранятся в SQLite на сервере бекенда (
|
||
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
|
||
). Каталог —{" "}
|
||
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="divide-y px-5 space-y-4 pb-5">
|
||
<SettingRow
|
||
label="Базовый URL API"
|
||
description="Например http://control.example:8080 — без суффикса /v1"
|
||
>
|
||
<Input
|
||
className="w-full max-w-md h-8 text-sm font-mono"
|
||
value={evoBaseDraft}
|
||
onChange={(e) => setEvoBaseDraft(e.target.value)}
|
||
placeholder="http://localhost:8080"
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="API-ключ"
|
||
description={
|
||
evo.secretConfigured
|
||
? "В БД уже есть ключ — введите новый только для замены"
|
||
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
|
||
}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
className="w-full max-w-md h-8 text-sm font-mono"
|
||
type={showEvoKey ? "text" : "password"}
|
||
autoComplete="off"
|
||
value={evoKeyDraft}
|
||
onChange={(e) => setEvoKeyDraft(e.target.value)}
|
||
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 shrink-0"
|
||
onClick={() => setShowEvoKey((v) => !v)}
|
||
>
|
||
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow
|
||
label="Подставлять данные EvoBGP"
|
||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||
>
|
||
<Toggle
|
||
checked={evoEnabledDraft}
|
||
onChange={(v) => setEvoEnabledDraft(v)}
|
||
/>
|
||
</SettingRow>
|
||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
|
||
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
|
||
onClick={async () => {
|
||
setEvoSaveBusy(true)
|
||
setEvoSaveErr(null)
|
||
try {
|
||
await evo.saveSettings({ apiKey: null })
|
||
setEvoKeyDraft("")
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
} catch (e) {
|
||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
|
||
} finally {
|
||
setEvoSaveBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
Удалить ключ из БД
|
||
</Button>
|
||
</div>
|
||
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
|
||
<p className="text-[11px] text-muted-foreground">
|
||
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» — только по сохранённым в БД настройкам.
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={
|
||
mode !== "live" ||
|
||
backendStatus !== true ||
|
||
evoBusy !== null ||
|
||
evoSaveBusy ||
|
||
!(
|
||
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
|
||
(evoKeyDraft.trim() || evo.secretConfigured)
|
||
)
|
||
}
|
||
onClick={async () => {
|
||
setEvoBusy("test")
|
||
setEvoTestResult(null)
|
||
const b = evoBaseDraft.trim()
|
||
const k = evoKeyDraft.trim()
|
||
let draft: EvoBgpTestDraft | undefined
|
||
if (b || k) {
|
||
draft = {}
|
||
if (b) draft.baseUrl = b
|
||
if (k) draft.apiKey = k
|
||
}
|
||
const r = await evo.testConnection(draft)
|
||
setEvoTestResult({ ok: r.ok, message: r.message })
|
||
setEvoBusy(null)
|
||
}}
|
||
>
|
||
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||
Проверить ключ
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8"
|
||
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
|
||
onClick={async () => {
|
||
setEvoBusy("refresh")
|
||
await evo.refresh()
|
||
setEvoBusy(null)
|
||
}}
|
||
>
|
||
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||
Обновить каталог
|
||
</Button>
|
||
{evo.snapshot?.fetchedAt && (
|
||
<span className="text-xs text-muted-foreground">
|
||
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{evoTestResult && (
|
||
<p className={cn(
|
||
"text-xs",
|
||
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
|
||
)}>
|
||
{evoTestResult.message}
|
||
</p>
|
||
)}
|
||
{evo.error && (
|
||
<p className="text-xs text-destructive">{evo.error}</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
|
||
// ── Уведомления ──
|
||
if (section === "Уведомления") return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
||
</SettingRow>
|
||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
|
||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||
<Toggle checked={notifSlack} onChange={setNotifSlack} />
|
||
</SettingRow>
|
||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
||
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||
<Toggle checked={notifWh} onChange={setNotifWh} />
|
||
</SettingRow>
|
||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||
<Toggle checked={notifDegr} onChange={setNotifDegr} />
|
||
</SettingRow>
|
||
<SettingRow label="Узел ушёл offline">
|
||
<Toggle checked={notifOffline} onChange={setNotifOffline} />
|
||
</SettingRow>
|
||
<SettingRow label="Падение BGP-сессии">
|
||
<Toggle checked={notifBgp} onChange={setNotifBgp} />
|
||
</SettingRow>
|
||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||
<Toggle checked={notifBackup} onChange={setNotifBackup} />
|
||
</SettingRow>
|
||
</CardContent>
|
||
</Card>
|
||
</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>
|
||
|
||
<Card className="overflow-hidden gap-0 py-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>
|
||
</Card>
|
||
|
||
{/* access summary */}
|
||
<Card className="overflow-hidden gap-0 py-0">
|
||
<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>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
|
||
<th className="text-left font-medium px-4 py-2">Пользователь</th>
|
||
<th className="text-left font-medium px-4 py-2">Разделы</th>
|
||
<th className="text-left font-medium px-4 py-2">Серверы</th>
|
||
<th className="text-left font-medium px-4 py-2">Права записи</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border/60">
|
||
{users.map(u => {
|
||
const writeSections = u.role === "admin" ? ALL_SECTIONS : 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)
|
||
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
|
||
return (
|
||
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||
<span className="text-sm font-medium">{u.name}</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-xs text-muted-foreground">
|
||
{u.role === "admin"
|
||
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
|
||
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
|
||
</td>
|
||
<td className="px-4 py-2.5 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>}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<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>
|
||
))
|
||
}
|
||
{u.role !== "admin" && writeSections.length > 3 && (
|
||
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
|
||
// ── API-ключи ──
|
||
if (section === "API-ключи") return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-end">
|
||
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{apiKeys.map(k => (
|
||
<Card key={k.id}>
|
||
<CardContent className="pt-4 pb-3 px-4">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-medium">{k.name}</p>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<code className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{k.prefix}</code>
|
||
<button onClick={() => handleCopy(k.prefix)} className="text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||
{copied === k.prefix ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1 mt-2">
|
||
{k.scopes.map(s => (
|
||
<span key={s} className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{s}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<p className="text-xs text-muted-foreground">Создан: {k.created}</p>
|
||
<p className="text-xs text-muted-foreground mt-0.5">Использован: {k.last}</p>
|
||
<div className="flex gap-1 justify-end mt-2">
|
||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs">Переиздать</Button>
|
||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"
|
||
onClick={() => setApiKeys(p => p.filter(x => x.id !== k.id))}>
|
||
<TrashIcon className="size-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-sm">Документация API</CardTitle>
|
||
<CardDescription className="text-xs">Base URL: https://api.routerlists.io/v1</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4">
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
{[
|
||
["GET", "/servers", "Список серверов"],
|
||
["GET", "/traffic", "Данные трафика"],
|
||
["GET", "/filters", "Фильтры маршрутов"],
|
||
["POST", "/filters", "Создать фильтр"],
|
||
["GET", "/probes", "Диагностические пробы"],
|
||
["POST", "/probes/run", "Запустить тест"],
|
||
].map(([method, path, desc], i) => (
|
||
<div key={`${method}-${path}-${i}`} className="flex items-baseline gap-2">
|
||
<span className={cn("font-mono text-[10px] font-bold w-8 shrink-0",
|
||
method === "GET" ? "text-emerald-500" : "text-sky-500")}>{method}</span>
|
||
<code className="font-mono text-muted-foreground">{path}</code>
|
||
<span className="text-muted-foreground/60 text-[10px]">{desc}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
|
||
// ── Безопасность ──
|
||
if (section === "Безопасность") return (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base">Аутентификация</CardTitle></CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||
description="TOTP / Authenticator app для всех администраторов">
|
||
<Toggle checked={mfa} onChange={setMfa} />
|
||
</SettingRow>
|
||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||
</SettingRow>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Allowlist IP-адресов</CardTitle>
|
||
<CardDescription className="text-xs">Доступ разрешён только с этих сетей. Одна запись на строку.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4">
|
||
<textarea value={ipAllow} onChange={e => setIpAllow(e.target.value)} rows={4}
|
||
className="w-full rounded-md border bg-muted px-3 py-2 text-xs font-mono resize-none outline-none focus:ring-1 focus:ring-ring" />
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base">Аудит</CardTitle></CardHeader>
|
||
<CardContent className="divide-y px-5">
|
||
<SettingRow label="Расширенный журнал аудита"
|
||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||
<Toggle checked={auditLog} onChange={setAuditLog} />
|
||
</SettingRow>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader><CardTitle className="text-base text-destructive">Опасная зона</CardTitle></CardHeader>
|
||
<CardContent className="px-4 pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Сбросить все настройки</p>
|
||
<p className="text-xs text-muted-foreground">Удалит пользовательские параметры и вернёт заводские значения</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground">
|
||
Сбросить
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||
actions={
|
||
<Button
|
||
size="sm"
|
||
onClick={() => { void handleSave() }}
|
||
disabled={
|
||
section === "EvoBGP" &&
|
||
(evoSaveBusy || mode !== "live" || backendStatus !== true)
|
||
}
|
||
>
|
||
{section === "EvoBGP" && evoSaveBusy ? (
|
||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||
) : saved ? (
|
||
<CheckIcon className="size-4" />
|
||
) : (
|
||
<SaveIcon className="size-4" />
|
||
)}
|
||
{section === "EvoBGP" && evoSaveBusy
|
||
? "Сохранение…"
|
||
: saved
|
||
? "Сохранено!"
|
||
: "Сохранить"}
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="max-w-[960px] mx-auto grid grid-cols-[180px_1fr] gap-6">
|
||
|
||
{/* nav */}
|
||
<nav className="flex flex-col gap-1 self-start sticky top-0">
|
||
{SECTIONS_NAV.map(s => (
|
||
<button key={s} onClick={() => setSection(s)}
|
||
className={cn(
|
||
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{/* content */}
|
||
<div className="min-w-0">{renderContent()}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* user sheet */}
|
||
<UserSheet
|
||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||
open={sheetOpen}
|
||
user={editUser}
|
||
onSave={handleUserSave}
|
||
onClose={() => setSheetOpen(false)}
|
||
/>
|
||
|
||
{/* delete confirm */}
|
||
{deleteTarget && (
|
||
<DeleteConfirm
|
||
user={deleteTarget}
|
||
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|