fix(settings): убрать фиктивные операции и подключить реальные параметры

This commit is contained in:
Denozordec committed 2026-10-03 14:21:17 +07:00
1 parent 2b6d3ab2e0
commit b6f1b72500
14 files changed
+76 -320

No files matched your search

+39 -318
View File
@@ -1,17 +1,15 @@
"use client" "use client"
import { useCallback, useEffect, useState } from "react" import { useCallback, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { useTheme } from "@/components/theme-provider"
import { FileImportDialog } from "@/components/file-import-dialog" import { FileImportDialog } from "@/components/file-import-dialog"
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section" import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -24,35 +22,26 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { import {
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon, CheckIcon,
AlertCircleIcon, AlertCircleIcon,
RefreshCwIcon, LoaderCircleIcon, LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon, DownloadIcon, UploadIcon, XIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database" import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner" import { toast } from "sonner"
interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] }
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 SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number] type NavSection = typeof SECTIONS_NAV[number]
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center justify-between gap-4 py-3.5"> <div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium">{label}</p> <p className="text-sm font-medium">{label}</p>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>} {description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div> </div>
<div className="shrink-0">{children}</div> <div className="min-w-0 max-w-full">{children}</div>
</div> </div>
) )
} }
@@ -103,66 +92,13 @@ function DatabaseRestoreConfirm({
export default function SettingsPage() { export default function SettingsPage() {
const [section, setSection] = useState<NavSection>("Общие") const [section, setSection] = useState<NavSection>("Общие")
const [saved, setSaved] = useState(false) const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
const [copied, setCopied] = useState<string | null>(null) const { theme, setTheme } = useTheme()
// data source
const { mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
const [urlDraft, setUrlDraft] = useState(backendUrl)
useEffect(() => {
setUrlDraft(backendUrl)
}, [backendUrl])
const commitBackendUrl = useCallback(() => {
const normalized = urlDraft.trim().replace(/\/$/, "")
setUrlDraft(normalized)
setBackendUrl(normalized)
void checkBackend()
}, [urlDraft, setBackendUrl, checkBackend])
// 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 [dbBackupBusy, setDbBackupBusy] = useState(false)
const [dbRestoreBusy, setDbRestoreBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null) const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false) 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)
// 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 handleSave = markSaved
const systemDbAvailable = mode === "live" && backendStatus === true const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => { const handleSystemDatabaseBackup = useCallback(async () => {
@@ -201,7 +137,6 @@ export default function SettingsPage() {
const renderContent = () => { const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
// ── Общие ── // ── Общие ──
if (section === "Общие") return ( if (section === "Общие") return (
@@ -212,18 +147,18 @@ export default function SettingsPage() {
title="Источник данных" title="Источник данных"
description={ description={
mockModeAvailable mockModeAvailable
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts" ? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
: "В продакшен-образе доступны только живые данные от бекенда" : "Источник живых данных задан конфигурацией приложения."
} }
contentClassName="divide-y px-5" contentClassName="divide-y px-5"
> >
{/* mode toggle */} {/* mode toggle */}
{mockModeAvailable ? ( {mockModeAvailable ? (
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд"> <SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
<div className="flex rounded-md border border-input overflow-hidden h-8"> <div className="flex rounded-md border border-input overflow-hidden h-8">
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => ( {([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
<button key={v} onClick={() => setMode(v)} <button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
className={cn( className={cn(
"px-3 text-xs transition-colors border-input", "px-3 text-xs transition-colors border-input",
i === 0 && "border-r", i === 0 && "border-r",
@@ -249,31 +184,11 @@ export default function SettingsPage() {
label="URL бекенда" label="URL бекенда"
description={ description={
backendUrlLocked backendUrlLocked
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра." ? "Адрес подключения задан конфигурацией приложения."
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)" : "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
} }
> >
<div className="flex items-center gap-2"> <BackendUrlField key={backendUrl} />
<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> </SettingRow>
{/* connection status */} {/* connection status */}
@@ -306,47 +221,18 @@ export default function SettingsPage() {
{mode === "live" && backendStatus === false && ( {mode === "live" && backendStatus === false && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-600 dark:text-amber-400"> <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" /> <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> <span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
</div> </div>
)} )}
</OpsPanel> </OpsPanel>
{/* ── Основные настройки ── */} <OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
<OpsPanel title="Основные настройки" contentClassName="divide-y px-5"> <SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
<SettingRow label="Язык интерфейса"> <SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
<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" {([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
value={lang} onChange={e => setLang(e.target.value)}> </div></SettingRow>
<option value="ru">Русский</option> <p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
<option value="en">English</option> <div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
</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>
</OpsPanel> </OpsPanel>
<OpsPanel <OpsPanel
@@ -404,198 +290,22 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
</OpsPanel> </OpsPanel>
<OpsPanel <OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
id="route-ai"
className="scroll-mt-4"
title="Route AI"
description="Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и веса настраиваются в инструменте, не через отдельный API."
contentClassName="flex flex-col gap-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>
</OpsPanel>
</div> </div>
) )
if (section === "EvoBGP") return <EvoBgpSettingsSection /> if (section === "EvoBGP") return <EvoBgpSettingsSection />
// ── Уведомления ── if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
if (section === "Уведомления") return ( if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
<div className="flex flex-col gap-4"> return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
<OpsPanel title="Каналы уведомлений" contentClassName="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>}
</OpsPanel>
<OpsPanel title="Триггеры" contentClassName="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>
</OpsPanel>
</div>
)
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div>
<div className="flex flex-col gap-3">
{apiKeys.map(k => (
<Frame key={k.id} dense className="w-full">
<FramePanel 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>
</FramePanel>
</Frame>
))}
</div>
<OpsPanel
title="Документация API"
description="Base URL: https://api.routerlists.io/v1"
contentClassName="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>
</OpsPanel>
</div>
)
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="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>
</OpsPanel>
<OpsPanel
title="Allowlist IP-адресов"
description="Доступ разрешён только с этих сетей. Одна запись на строку."
contentClassName="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" />
</OpsPanel>
<OpsPanel title="Аудит" contentClassName="divide-y px-5">
<SettingRow label="Расширенный журнал аудита"
description="Записывать все изменения конфигурации с указанием пользователя и IP">
<FormToggle checked={auditLog} onChange={setAuditLog} />
</SettingRow>
</OpsPanel>
<OpsPanel title={<span className="text-destructive">Опасная зона</span>} contentClassName="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>
</OpsPanel>
</div>
)
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
crumbs={[{ label: "Система" }, { label: "Настройки" }]} crumbs={[{ label: "Система" }, { label: "Настройки" }]}
actions={section === "EvoBGP" ? null : <Button size="sm" onClick={handleSave}>
{saved ? <CheckIcon className="size-4" /> : <SaveIcon className="size-4" />}
{saved ? "Сохранено!" : "Сохранить"}
</Button>}
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
@@ -604,7 +314,7 @@ export default function SettingsPage() {
{/* nav */} {/* nav */}
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0"> <nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
{SECTIONS_NAV.map(s => ( {SECTIONS_NAV.map(s => (
<button key={s} onClick={() => setSection(s)} <button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
className={cn( className={cn(
"text-left px-3 py-2 rounded-md text-sm transition-colors", "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 === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
@@ -649,3 +359,14 @@ export default function SettingsPage() {
) )
} }
function BackendUrlField(){
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
function commit(){
if(backendUrlLocked||draft===backendUrl)return
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
}
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
}
+2
View File
@@ -284,3 +284,5 @@ REST запрос → ожидание → подтверждённый output /
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution. Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes. Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes.
Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.6.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.6.1 — достоверные общие Settings (2026-10-03)
- Удалены фиктивный общий Save/toast, demo API keys, неработающие MFA/IP/audit/reset, язык English/timezone/polling/probe timeout без реализации, default-only Route AI KPI. Вместо них — явные unavailable состояния и переходы к существующим Alerts/Users/Data collection/Probes/Route optimizer. Это исправление ложных операций, не реализация нового API keys/MFA сервиса.
- Theme подключён к существующему ThemeProvider: реальный document class/local storage, применяется сразу. Режим источника применяется сразу и отмечен aria-pressed; Settings navigation aria-current. Backend URL выделен в компонент keyed текущим URL, sync-effect lint устранён; HTTP(S) URL canonicalized, credentials/query/fragment/invalid input отклоняются, черновик сохраняется. Fixed/same-origin конфигурация read-only.
- Общие rows и Settings navigation адаптированы для телефона. Не обещать fallback mocks при недоступном live backend. System DB backup/restore ещё старые и требуют отдельной приёмки; общие Settings целиком пока не приняты.
- В наборе 32 E2E (7 Evolution form, 17 NetFlow, 8 Settings) 31 прошёл сразу, один Settings link locator уточнён до main-content (такой же link есть в Sidebar) и повтор прошёл. 3 local source E2E прошли после уточнения role alert (Next announcer тоже alert); 8 Evolution source E2E прошли после смены URL поля. Typecheck, Settings/test lint и production frontend/contracts build прошли.
- 6 general/security screenshots 1440/390/320; 320 general/security просмотрены. Реальные PG/Portal не приняты. Следующий небольшой блок 11Б.3.6.1.2 — stale health/ABA и корректный health response, затем 11Б.3.6.2 system DB API/operations.
## Блок 11Б.3.5.5 — серверная и локальная приёмка Evolution (2026-10-03) ## Блок 11Б.3.5.5 — серверная и локальная приёмка Evolution (2026-10-03)
- Catalog handler вынесен в DI plugin, production route использует те же функции. Enabled/URL/key читаются одним снимком настроек (раньше двумя GET БД), credentials поступают только из сохранённых данных, request body не переопределяет URL/key. Mapping сохраняет null/0 и local server associations; timestamps берутся из одного now. - Catalog handler вынесен в DI plugin, production route использует те же функции. Enabled/URL/key читаются одним снимком настроек (раньше двумя GET БД), credentials поступают только из сохранённых данных, request body не переопределяет URL/key. Mapping сохраняет null/0 и local server associations; timestamps берутся из одного now.
+2
View File
@@ -216,3 +216,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution. Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes. Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes.
Завершён 11Б.3.6.1: общие Settings без фиктивных сохранений/demo keys/MFA, реальная тема/source URL, переходы к инструментам; 32 E2E с одним исправленным locator, 3 source с исправленным alert locator, 8 Evolution source, typecheck/lint/build прошли. 6 screenshots, 320 просмотрены. System DB ещё не принят. Далее 11Б.3.6.1.2 health/source guards; затем 11Б.3.6.2 DB operations.
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], testIgnore: ["**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({ ...base, use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, testIgnore: [], testMatch: "settings-context.spec.ts", webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } } })
+8
View File
@@ -0,0 +1,8 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{headers,json:{required:false}}:path==="/api/evobgp/settings"?{headers,json:evoSettings}:path==="/api/evobgp/catalog"?{headers,json:evoCatalog}:{headers,status:503,json:{error:"outside source fixture"}})});await page.goto("/settings");await expect(page.getByRole("textbox",{name:"URL бекенда",exact:true})).toHaveValue("http://localhost:8000")}
test("Settings source: invalid backend URL stays draft without changing active source",async({page})=>{await setup(page);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});for(const value of ["bad","ftp://host.test","https://user:secret@host.test","https://host.test?token=secret"]){await input.fill(value);await input.press("Enter");await expect(page.locator("#main-content").getByRole("alert")).toContainText("Укажите HTTP(S) адрес");expect(await page.evaluate(()=>localStorage.getItem("routerlists:backend-url"))).toBeNull();await expect(input).toHaveValue(value)}})
test("Settings source: URL canonicalizes and persists after Enter",async({page})=>{await setup(page);const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(" http://LOCALHOST:8101/ ");await input.press("Enter");await expect(input).toHaveValue("http://localhost:8101");expect(await page.evaluate(()=>localStorage.getItem("routerlists:backend-url"))).toBe("http://localhost:8101");await page.reload();await expect(input).toHaveValue("http://localhost:8101")})
test("Settings source: explicit demo/live mode persists",async({page})=>{await setup(page);await page.getByRole("button",{name:"Моковые",exact:true}).click();expect(await page.evaluate(()=>localStorage.getItem("routerlists:data-source"))).toBe("mock");await page.reload();await expect(page.getByRole("button",{name:"Моковые",exact:true})).toBeVisible();await page.getByRole("button",{name:"Живые",exact:true}).click();expect(await page.evaluate(()=>localStorage.getItem("routerlists:data-source"))).toBe("live")})
+10
View File
@@ -0,0 +1,10 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:evoCatalog}:{status:503,json:{error:"outside settings fixture"}})});await page.goto("/settings")}
test("Settings: general does not claim unsupported saving and links actual tools",async({page})=>{await setup(page);await expect(page.getByRole("button",{name:"Сохранить",exact:true})).toHaveCount(0);await expect(page.locator("#main-content").getByRole("link",{name:"Сбор данных",exact:true})).toHaveAttribute("href","/data-collection");await expect(page.locator("#main-content").getByRole("link",{name:"Диагностика",exact:true})).toHaveAttribute("href","/probes");await expect(page.locator("#main-content").getByRole("link",{name:"Открыть оптимизатор",exact:true})).toHaveAttribute("href","/route-optimizer");await expect(page.getByText("Мин. выигрыш (переключение)",{exact:true})).toHaveCount(0)})
test("Settings: theme applies to document and survives reload",async({page})=>{await setup(page);await page.getByRole("button",{name:"Светлая",exact:true}).click();await expect(page.locator("html")).toHaveClass(/light/);expect(await page.evaluate(()=>localStorage.getItem("rl-theme"))).toBe("light");await page.reload();await expect(page.getByRole("button",{name:"Светлая",exact:true})).toHaveAttribute("aria-pressed","true");await expect(page.locator("html")).toHaveClass(/light/)})
test("Settings: notifications lead to rules without invented channels",async({page})=>{await setup(page);await page.getByRole("button",{name:"Уведомления",exact:true}).click();await expect(page.locator("#main-content").getByRole("link",{name:"Открыть оповещения",exact:true})).toHaveAttribute("href","/alerts");await expect(page.getByRole("switch")).toHaveCount(0);await expect(page.getByText("admin@routerlists.io",{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Сохранить",exact:true})).toHaveCount(0)})
test("Settings: API keys and security do not masquerade as configured features",async({page})=>{await setup(page);await page.getByRole("button",{name:"API-ключи",exact:true}).click();await expect(page.getByText("Создание и управление отдельными API-ключами в приложении пока недоступны.",{exact:true})).toBeVisible();await expect(page.getByText("Monitoring Script",{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Создать ключ",exact:true})).toHaveCount(0);await page.getByRole("button",{name:"Безопасность",exact:true}).click();await expect(page.getByRole("status").filter({hasText:/Состояние MFA/})).toBeVisible();await expect(page.getByRole("switch")).toHaveCount(0);await expect(page.getByRole("textbox")).toHaveCount(0);await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toHaveCount(0);await expect(page.locator("#main-content").getByRole("link",{name:"Управление пользователями",exact:true})).toHaveAttribute("href","/users")})
test("Settings: configured backend URL remains read only",async({page})=>{await setup(page);await expect(page.getByRole("textbox",{name:"URL бекенда",exact:true})).toHaveAttribute("readonly","")})
for(const width of [1440,390,320])test("Settings: adaptive general/"+width,async({page})=>{await page.setViewportSize({width,height:1000});await setup(page);await expect(page.getByRole("button",{name:"Тёмная",exact:true})).toBeEnabled();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/settings-general-"+width+".png",fullPage:true,animations:"disabled"});await page.getByRole("button",{name:"Безопасность",exact:true}).click();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/settings-security-"+width+".png",fullPage:true,animations:"disabled"})})