fix(settings): убрать фиктивные операции и подключить реальные параметры
This commit is contained in:
1 parent
2b6d3ab2e0
commit
b6f1b72500
14 files changed
+76
-320
No files matched your search
+39
-318
@@ -1,17 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useState } from "react"
|
||||
import Link from "next/link"
|
||||
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 { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS } from "@/lib/route-optimizer-data"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -24,35 +22,26 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
SaveIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
|
||||
CheckIcon,
|
||||
AlertCircleIcon,
|
||||
RefreshCwIcon, LoaderCircleIcon,
|
||||
LoaderCircleIcon,
|
||||
DownloadIcon, UploadIcon, XIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||
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
|
||||
type NavSection = typeof SECTIONS_NAV[number]
|
||||
|
||||
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="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 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 className="min-w-0 max-w-full">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,66 +92,13 @@ function DatabaseRestoreConfirm({
|
||||
|
||||
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 [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 { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend } = useDataSource()
|
||||
const { theme, setTheme } = useTheme()
|
||||
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)
|
||||
|
||||
// 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 handleSystemDatabaseBackup = useCallback(async () => {
|
||||
@@ -201,7 +137,6 @@ export default function SettingsPage() {
|
||||
|
||||
|
||||
const renderContent = () => {
|
||||
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
|
||||
|
||||
// ── Общие ──
|
||||
if (section === "Общие") return (
|
||||
@@ -212,18 +147,18 @@ export default function SettingsPage() {
|
||||
title="Источник данных"
|
||||
description={
|
||||
mockModeAvailable
|
||||
? "Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts"
|
||||
: "В продакшен-образе доступны только живые данные от бекенда"
|
||||
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
|
||||
: "Источник живых данных задан конфигурацией приложения."
|
||||
}
|
||||
contentClassName="divide-y px-5"
|
||||
>
|
||||
|
||||
{/* mode toggle */}
|
||||
{mockModeAvailable ? (
|
||||
<SettingRow label="Режим данных" description="Моковые — статичные тестовые данные; Живые — RouterOS REST API через бекенд">
|
||||
<SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
|
||||
<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)}
|
||||
<button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
|
||||
className={cn(
|
||||
"px-3 text-xs transition-colors border-input",
|
||||
i === 0 && "border-r",
|
||||
@@ -249,31 +184,11 @@ export default function SettingsPage() {
|
||||
label="URL бекенда"
|
||||
description={
|
||||
backendUrlLocked
|
||||
? "В Docker-образе URL API задаётся через прокси фронтенда на backend; поле только для просмотра."
|
||||
: "Адрес Node.js/Fastify сервера (запускается командой npm run dev в /backend)"
|
||||
? "Адрес подключения задан конфигурацией приложения."
|
||||
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<BackendUrlField key={backendUrl} />
|
||||
</SettingRow>
|
||||
|
||||
{/* connection status */}
|
||||
@@ -306,47 +221,18 @@ export default function SettingsPage() {
|
||||
{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>
|
||||
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
||||
</div>
|
||||
)}
|
||||
</OpsPanel>
|
||||
|
||||
{/* ── Основные настройки ── */}
|
||||
<OpsPanel title="Основные настройки" contentClassName="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>
|
||||
<OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
|
||||
<SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
|
||||
<SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
|
||||
{([["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>)}
|
||||
</div></SettingRow>
|
||||
<p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
|
||||
<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>
|
||||
</OpsPanel>
|
||||
|
||||
<OpsPanel
|
||||
@@ -404,198 +290,22 @@ export default function SettingsPage() {
|
||||
</SettingRow>
|
||||
</OpsPanel>
|
||||
|
||||
<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>
|
||||
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
if (section === "EvoBGP") return <EvoBgpSettingsSection />
|
||||
|
||||
// ── Уведомления ──
|
||||
if (section === "Уведомления") return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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>
|
||||
)
|
||||
if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
|
||||
if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
|
||||
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>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
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">
|
||||
@@ -604,7 +314,7 @@ export default function SettingsPage() {
|
||||
{/* nav */}
|
||||
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
|
||||
{SECTIONS_NAV.map(s => (
|
||||
<button key={s} onClick={() => setSection(s)}
|
||||
<button key={s} aria-current={s===section?"page":undefined} 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",
|
||||
@@ -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>
|
||||
}
|
||||
Reference in new issue
Block a user