Files
MikrotikManager/app/(main)/settings/page.tsx
T
Denozordec d3a2d38b37
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
2026-06-30 22:22:51 +07:00

1710 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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 { FileImportDialog } from "@/components/file-import-dialog"
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,
DownloadIcon, UploadIcon,
} from "lucide-react"
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner"
// ─── types ────────────────────────────────────────────────────────────────────
type Role = "admin" | "operator" | "viewer"
type PermLevel = "none" | "read" | "write"
interface SectionPerm { section: string; level: PermLevel }
interface ServerPerm { serverId: string; level: PermLevel }
interface User {
id: string; name: string; login: string; email: string
role: Role; last: string; avatar: string; active: boolean
sections: SectionPerm[]; servers: ServerPerm[]
subUsers: SubUser[]
}
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
// Подчинённый пользователь — учётка для подключения устройства к JH через GRE
interface SubUser {
id: string
login: string
password: string
description: string // "офисный роутер", "склад", и т.п.
jhServerIds: string[] // на каких JH зарегистрирована учётка
clientIp: string // назначенный IP внутри туннеля
active: boolean
lastSeen: string | null
}
// ─── section definitions ──────────────────────────────────────────────────────
const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [
{ group: "Обзор", icon: <LayoutDashboardIcon className="size-3" />, items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] },
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
]
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
// ─── default permissions by role ──────────────────────────────────────────────
function defaultSections(role: Role): SectionPerm[] {
return ALL_SECTIONS.map(section => {
let level: PermLevel = "none"
if (role === "admin") level = "write"
else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read"
else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read"
return { section, level }
})
}
function defaultServers(role: Role): ServerPerm[] {
return servers.map(s => ({
serverId: s.id,
level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel,
}))
}
// ─── initial data ──────────────────────────────────────────────────────────────
const INIT_USERS: User[] = [
{
id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io",
role: "admin", last: "сейчас", avatar: "АК", active: true,
sections: defaultSections("admin"), servers: defaultServers("admin"),
subUsers: [
{ id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" },
{ id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" },
{ id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" },
],
},
{
id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io",
role: "operator", last: "2ч назад", avatar: "ДФ", active: true,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" },
],
},
{
id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io",
role: "viewer", last: "вчера", avatar: "МС", active: true,
sections: defaultSections("viewer"), servers: defaultServers("viewer"),
subUsers: [],
},
{
id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io",
role: "operator", last: "3 дн назад", avatar: "ИП", active: false,
sections: defaultSections("operator"), servers: defaultServers("operator"),
subUsers: [
{ id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null },
],
},
]
const INIT_API_KEYS: ApiKey[] = [
{ id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] },
{ 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]
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
// ─── 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">
<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">
<FormField label="Полное имя" error={errors.name}>
<Input value={form.name} onChange={e => setField("name", e.target.value)}
placeholder="Иван Иванов" className="h-9" />
</FormField>
<FormField label="Логин" error={errors.login}>
<Input value={form.login} onChange={e => setField("login", e.target.value)}
placeholder="i.ivanov" className="h-9 font-mono" />
</FormField>
<FormField label="Email" error={errors.email}>
<Input value={form.email} onChange={e => setField("email", e.target.value)}
placeholder="i.ivanov@company.io" type="email" className="h-9" />
</FormField>
<Separator />
<FormField label="Роль">
<div className="flex gap-2 flex-wrap">
{(["viewer", "operator", "admin"] as Role[]).map(r => (
<button key={r} type="button" onClick={() => setRole(r)}
className={cn(
"px-3 py-1.5 rounded-md border text-xs font-medium transition-all",
form.role === r
? r === "admin" ? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/30 ring-1 ring-violet-500/30"
: r === "operator" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/30 ring-1 ring-sky-500/30"
: "bg-muted text-foreground border-border ring-1 ring-border"
: "border-border text-muted-foreground hover:bg-muted",
)}>
{ROLE_LABEL[r]}
</button>
))}
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
{form.role === "admin"
? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав"
: form.role === "operator"
? "Управление инфраструктурой согласно выданным правам"
: "Только просмотр согласно выданным правам"}
</p>
</FormField>
<Separator />
<FormField label="Статус учётной записи">
<div className="flex items-center gap-3">
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{form.active ? "Активна" : "Заблокирована"}
</span>
</div>
</FormField>
</div>
)}
{/* ── Sections tab ── */}
{tab === "sections" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllSections(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
{SECTION_GROUPS.map(group => (
<div key={group.group}>
{/* group header */}
<div className="flex items-center gap-2 px-4 py-1.5 bg-muted/20 border-b">
<span className="text-muted-foreground">{group.icon}</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group.group}
</span>
</div>
{/* section rows */}
<div className="divide-y divide-border/60">
{group.items.map(section => {
const perm = form.sections.find(s => s.section === section)
const level = perm?.level ?? "none"
return (
<div key={section} className="flex items-center justify-between px-4 py-2.5 hover:bg-muted/20 transition-colors">
<span className="text-sm">{section}</span>
<PermPills value={level} onChange={v => setSectionPerm(section, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
))}
</div>
)}
{/* ── Servers tab ── */}
{tab === "servers" && (
<div className="flex flex-col">
{/* bulk actions */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<span className="text-xs text-muted-foreground mr-1">Выбрать всё:</span>
{PERM_OPTS.map(o => (
<button key={o.v} type="button" onClick={() => setAllServers(o.v)}
disabled={isAdmin}
className={cn(
"text-[11px] px-2 py-0.5 rounded border transition-colors",
isAdmin ? "opacity-30 cursor-not-allowed" : "hover:bg-muted cursor-pointer",
PERM_COLOR[o.v], "border-border",
)}>
{o.label}
</button>
))}
{isAdmin && (
<span className="ml-auto text-[11px] text-violet-600 dark:text-violet-400 flex items-center gap-1">
<ShieldIcon className="size-3" />Администратор имеет полный доступ
</span>
)}
</div>
<div className="divide-y divide-border/60">
{servers.map(srv => {
const perm = form.servers.find(s => s.serverId === srv.id)
const level = perm?.level ?? "none"
return (
<div key={srv.id} className="flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors">
<div className="flex items-center gap-2.5 min-w-0">
<StatusDot status={srv.status} />
{srv.country && <Flag code={srv.country} size={14} />}
<div className="min-w-0">
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
</div>
</div>
<PermPills value={level} onChange={v => setServerPerm(srv.id, v)} disabled={isAdmin} />
</div>
)
})}
</div>
</div>
)}
{/* ── GRE-клиенты tab ── */}
{tab === "subusers" && (
<div className="flex flex-col">
{/* hint */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-muted/30">
<CableIcon className="size-3.5 text-muted-foreground shrink-0" />
<p className="text-[11px] text-muted-foreground">
Учётные записи для подключения устройств к JH через GRE
</p>
</div>
<SubusersDataGrid
subUsers={form.subUsers}
servers={servers}
revealedIds={revealedIds}
onToggleReveal={toggleReveal}
onToggleActive={toggleSubUser}
onRemove={removeSubUser}
/>
{/* inline add form */}
{addSubOpen ? (
<div className="border-t bg-muted/20 px-4 py-4 flex flex-col gap-3">
<p className="text-xs font-medium text-muted-foreground">Новый GRE-клиент</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Логин</label>
<Input className="h-8 text-xs font-mono" placeholder="gre-office-msk"
value={newSubLogin} onChange={e => setNewSubLogin(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Описание</label>
<Input className="h-8 text-xs" placeholder="Офис MSK"
value={newSubDesc} onChange={e => setNewSubDesc(e.target.value)} />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Пароль</label>
<div className="flex gap-1.5">
<Input className="h-8 text-xs font-mono flex-1" placeholder="••••••••"
value={newSubPwd} onChange={e => setNewSubPwd(e.target.value)} />
<Button variant="outline" size="sm" className="h-8 px-2 shrink-0"
onClick={() => setNewSubPwd(genPassword())} title="Сгенерировать пароль">
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">
JH-серверы
<span className="ml-1 text-muted-foreground/50">(можно выбрать несколько)</span>
</label>
<div className="flex flex-col gap-1 rounded-md border border-input bg-background px-2 py-1.5">
{jhServers.map(s => {
const checked = newSubJhs.includes(s.id)
return (
<label key={s.id}
className="flex items-center gap-2 py-0.5 cursor-pointer hover:text-foreground transition-colors">
<input type="checkbox" checked={checked}
onChange={() => toggleNewSubJh(s.id)}
className="rounded border-input accent-primary" />
<Flag code={s.country} size={12} />
<span className="font-mono text-xs">{s.name}</span>
<span className="text-[10px] text-muted-foreground">{s.site}</span>
</label>
)
})}
</div>
{newSubJhs.length === 0 && (
<p className="text-[10px] text-destructive">Выберите хотя бы один JH-сервер</p>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-muted-foreground">IP-клиента</label>
<Input className="h-8 text-xs font-mono" placeholder="10.210.0.18"
value={newSubIp} onChange={e => setNewSubIp(e.target.value)} />
</div>
<div className="flex gap-2 pt-1">
<Button variant="outline" size="sm" className="flex-1"
onClick={() => { setAddSubOpen(false); setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) }}>
Отмена
</Button>
<Button size="sm" className="flex-1"
disabled={!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0}
onClick={addSubUser}>
<PlusIcon className="size-3.5" />Добавить
</Button>
</div>
</div>
) : (
<button
onClick={() => {
setNewSubLogin(genSubLogin(form.login, form.subUsers))
setNewSubPwd(genPassword())
setNewSubJhs(jhServers.length > 0 ? [jhServers[0].id] : [])
setAddSubOpen(true)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />Добавить GRE-клиента
</button>
)}
</div>
)}
</div>
{/* footer */}
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1">
<CheckIcon className="size-4" />
{isCreate ? "Создать" : "Сохранить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
filename,
busy,
onConfirm,
onCancel,
}: {
filename: string
busy?: boolean
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">
<AlertCircleIcon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-semibold">Восстановить базу приложения?</p>
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать
актуальный бэкап.
</p>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button>
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</Button>
</div>
</div>
</div>
)
}
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, backendUrlLocked, mockModeAvailable, 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])
useEffect(() => {
setUrlDraft(backendUrl)
}, [backendUrl])
const commitBackendUrl = useCallback(() => {
const normalized = urlDraft.trim().replace(/\/$/, "")
setUrlDraft(normalized)
setBackendUrl(normalized)
void checkBackend()
}, [urlDraft, setBackendUrl, checkBackend])
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// 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")
const [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
// 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)
toast.success("Скопировано в буфер обмена")
}
const markSaved = useCallback(() => {
setSaved(true)
toast.success("Настройки сохранены")
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() })
}, [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) {
markSaved()
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("")
markSaved()
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
} finally {
setEvoSaveBusy(false)
}
return
}
markSaved()
}, [
section,
mode,
backendStatus,
evoBaseDraft,
evoEnabledDraft,
evoKeyDraft,
markSaved,
evo.saveSettings,
])
const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success("Бэкап базы приложения скачан")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
} finally {
setDbBackupBusy(false)
}
}, [backendUrl, systemDbAvailable])
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
if (!dbRestoreFile || !systemDbAvailable) return
setDbRestoreBusy(true)
try {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
setDbRestoreBusy(false)
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
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">
{mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
: "В продакшен-образе доступны только живые данные от бекенда"}
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
{/* mode toggle */}
{mockModeAvailable ? (
<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>
) : (
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
<span className="inline-flex h-8 items-center rounded-md border border-input bg-emerald-600 px-3 text-xs text-white dark:bg-emerald-500">
Живые
</span>
</SettingRow>
)}
{/* backend URL — visible in both modes so user can configure before switching */}
<SettingRow
label="URL бекенда"
description={
backendUrlLocked
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
: "Адрес 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={commitBackendUrl}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault()
commitBackendUrl()
}
}}
readOnly={backendUrlLocked}
placeholder="http://localhost:8000"
/>
<Button variant="outline" size="sm" className="h-8 px-2.5 shrink-0"
onClick={commitBackendUrl}
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>
<CardHeader>
<CardTitle className="text-base">База данных приложения</CardTitle>
<CardDescription className="text-xs">
Резервная копия SQLite бекенда: серверы, мониторинг, оповещения, EvoBGP. На время операции планировщик
сбора данных приостанавливается.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5">
{!systemDbAvailable && (
<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>Доступно только в live-режиме при доступном бекенде.</span>
</div>
)}
<SettingRow
label="Скачать бэкап"
description="Консистентная копия файла mikrotik.db"
>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => { void handleSystemDatabaseBackup() }}
>
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{dbBackupBusy ? "Подготовка…" : "Скачать"}
</Button>
</SettingRow>
<SettingRow
label="Восстановить из файла"
description="Полностью заменяет текущую базу SQLite"
>
<div className="flex flex-col items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
</div>
</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>
)
// ── 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"
>
<FormToggle
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("")
markSaved()
} 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">
<FormToggle 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">
<FormToggle 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">
<FormToggle 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мс">
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
</SettingRow>
<SettingRow label="Узел ушёл offline">
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
</SettingRow>
<SettingRow label="Падение BGP-сессии">
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
</SettingRow>
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
<FormToggle 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 */}
<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="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 для всех администраторов">
<FormToggle 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">
<FormToggle 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 */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
}}
/>
)}
{deleteTarget && (
<DeleteConfirm
user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</div>
)
}