This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+525 -11
View File
@@ -1,13 +1,16 @@
"use client"
import { useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
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,
@@ -18,7 +21,7 @@ import { servers } from "@/lib/data"
import {
SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon,
XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon,
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
@@ -144,9 +147,43 @@ const PERM_COLOR: Record<PermLevel, string> = {
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
}
const SECTIONS_NAV = ["Общие", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number]
interface CollectorSettingsDto {
enabled: boolean
intervalSec: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays: number
lastCollectedAt: string | null
lastDurationMs: number | null
lastError: string | null
collectorRunning?: boolean
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
})
if (!res.ok) {
let message = res.statusText
try {
const err = await res.json() as { error?: string }
message = err.error ?? message
} catch {
const text = await res.text().catch(() => "")
if (text) message = text
}
throw new Error(message || "Ошибка запроса")
}
return res.json() as Promise<T>
}
}
// ─── small components ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -815,7 +852,28 @@ export default function SettingsPage() {
// data source
const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource()
const evo = useEvoBGP()
const [evoBaseDraft, setEvoBaseDraft] = useState("")
const [evoEnabledDraft, setEvoEnabledDraft] = useState(false)
const [evoKeyDraft, setEvoKeyDraft] = useState("")
const [evoSaveBusy, setEvoSaveBusy] = useState(false)
const [evoSaveErr, setEvoSaveErr] = useState<string | null>(null)
const [urlDraft, setUrlDraft] = useState(backendUrl)
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [evoTestResult, setEvoTestResult] = useState<{ ok: boolean; message: string } | null>(null)
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// collectors
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
const [collectorError, setCollectorError] = useState<string | null>(null)
// general
const [lang, setLang] = useState("ru")
@@ -848,8 +906,6 @@ export default function SettingsPage() {
const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16")
const [auditLog, setAuditLog] = useState(true)
const handleSave = () => { setSaved(true); setTimeout(() => setSaved(false), 2000) }
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {})
setCopied(text); setTimeout(() => setCopied(null), 1500)
@@ -871,7 +927,84 @@ export default function SettingsPage() {
// total sub-users count for summary
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
const loadCollectors = useCallback(async () => {
if (mode !== "live" || backendStatus !== true) return
setCollectorError(null)
try {
const [traffic, uptime] = await Promise.all([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
])
setTrafficCollector(traffic)
setUptimeCollector(uptime)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
}
}, [apiFetch, backendStatus, mode])
useEffect(() => {
if (section !== "Сбор данных") return
queueMicrotask(() => { void loadCollectors() })
}, [section, loadCollectors])
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
}, [section, mode, backendStatus, evo.loadSettings])
useEffect(() => {
if (section !== "EvoBGP" || !evo.settingsLoaded) return
setEvoBaseDraft(evo.baseUrl)
setEvoEnabledDraft(evo.enabled)
setEvoKeyDraft("")
setEvoSaveErr(null)
}, [section, evo.settingsLoaded, evo.baseUrl, evo.enabled])
const handleSave = useCallback(async () => {
if (section === "EvoBGP") {
if (mode !== "live" || backendStatus !== true) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
return
}
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
const patch: EvoBgpSavePayload = {
baseUrl: evoBaseDraft.trim(),
enabled: evoEnabledDraft,
}
if (evoKeyDraft.trim()) patch.apiKey = evoKeyDraft.trim()
await evo.saveSettings(patch)
setEvoKeyDraft("")
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
} finally {
setEvoSaveBusy(false)
}
return
}
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [
section,
mode,
backendStatus,
evoBaseDraft,
evoEnabledDraft,
evoKeyDraft,
evo.saveSettings,
])
const renderContent = () => {
const ra = DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS
// ── Общие ──
if (section === "Общие") return (
@@ -966,7 +1099,7 @@ export default function SettingsPage() {
<CardHeader><CardTitle className="text-base">Основные настройки</CardTitle></CardHeader>
<CardContent className="divide-y px-5">
<SettingRow label="Язык интерфейса">
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
<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>
@@ -984,7 +1117,7 @@ export default function SettingsPage() {
</div>
</SettingRow>
<SettingRow label="Часовой пояс">
<select className="text-sm bg-background border rounded-md px-2 py-1 h-8 focus:outline-none focus:ring-1 focus:ring-ring"
<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>
@@ -1002,6 +1135,370 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card id="route-ai" className="scroll-mt-4">
<CardHeader>
<CardTitle className="text-base">Route AI</CardTitle>
<CardDescription className="text-xs">
Параметры оптимизации маршрутов по умолчанию (тот же набор, что на странице «Оптимизатор маршрутов»). Пороги и
веса настраиваются в инструменте, не через отдельный API.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-5 pb-5">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2 text-xs">
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Мин. выигрыш (переключение)</dt>
<dd className="font-mono tabular-nums">{ra.switchThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Гистерезис</dt>
<dd className="font-mono tabular-nums">{ra.hysteresisThreshold}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Вес задержки (ping)</dt>
<dd className="font-mono tabular-nums">{ra.pingWeight}%</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5">
<dt className="text-muted-foreground">Интервал зондирования</dt>
<dd className="font-mono tabular-nums">{ra.probeIntervalMin} мин</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/50 py-1.5 sm:col-span-2">
<dt className="text-muted-foreground">Автоприменение</dt>
<dd>{ra.autoApply ? `да, каждые ${ra.autoApplyIntervalMin} мин` : "нет"}</dd>
</div>
</dl>
<Link
href="/route-optimizer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8")}
>
Открыть оптимизатор маршрутов
</Link>
</CardContent>
</Card>
</div>
)
// ── Сбор данных ──
if (section === "Сбор данных") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
</p>
</CardContent>
</Card>
)}
{mode === "live" && backendStatus === true && (
<>
{collectorError && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-xs text-destructive">{collectorError}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор трафика</CardTitle>
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try {
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор uptime</CardTitle>
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
</div>
<div className="grid grid-cols-1 gap-3">
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try {
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
}),
})
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
</>
)}
</div>
)
// ── EvoBGP ──
if (section === "EvoBGP") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-sm font-medium">Интеграция доступна в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Включите «Живые» данные и убедитесь, что локальный бекенд доступен (раздел «Общие»).
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">EvoBGP API</CardTitle>
<CardDescription className="text-xs">
Control plane EvoBGP: Bearer-ключ и роль viewer+ см.{" "}
<a
className="underline underline-offset-2"
href="https://git.shts.su/denozord/EvoBGP/src/branch/main/docs/access.md"
target="_blank"
rel="noreferrer"
>
access.md
</a>
. URL и ключ хранятся в SQLite на сервере бекенда (
<code className="font-mono bg-muted px-1 rounded">evobgp_settings</code>
). Каталог {" "}
<code className="font-mono bg-muted px-1 rounded">GET /v1/router-lists/catalog</code> через прокси.
</CardDescription>
</CardHeader>
<CardContent className="divide-y px-5 space-y-4 pb-5">
<SettingRow
label="Базовый URL API"
description="Например http://control.example:8080 — без суффикса /v1"
>
<Input
className="w-full max-w-md h-8 text-sm font-mono"
value={evoBaseDraft}
onChange={(e) => setEvoBaseDraft(e.target.value)}
placeholder="http://localhost:8080"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
</SettingRow>
<SettingRow
label="API-ключ"
description={
evo.secretConfigured
? "В БД уже есть ключ — введите новый только для замены"
: "Сохраняется общей кнопкой «Сохранить» в шапке страницы"
}
>
<div className="flex items-center gap-2">
<Input
className="w-full max-w-md h-8 text-sm font-mono"
type={showEvoKey ? "text" : "password"}
autoComplete="off"
value={evoKeyDraft}
onChange={(e) => setEvoKeyDraft(e.target.value)}
placeholder={evo.secretConfigured ? "Оставьте пустым, чтобы не менять" : "Bearer-токен"}
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => setShowEvoKey((v) => !v)}
>
{showEvoKey ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</Button>
</div>
</SettingRow>
<SettingRow
label="Подставлять данные EvoBGP"
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
>
<Toggle
checked={evoEnabledDraft}
onChange={(v) => setEvoEnabledDraft(v)}
/>
</SettingRow>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-destructive border-destructive/40 hover:bg-destructive/10"
disabled={mode !== "live" || backendStatus !== true || evoSaveBusy || !evo.secretConfigured}
onClick={async () => {
setEvoSaveBusy(true)
setEvoSaveErr(null)
try {
await evo.saveSettings({ apiKey: null })
setEvoKeyDraft("")
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка")
} finally {
setEvoSaveBusy(false)
}
}}
>
Удалить ключ из БД
</Button>
</div>
{evoSaveErr && <p className="text-xs text-destructive">{evoSaveErr}</p>}
<p className="text-[11px] text-muted-foreground">
«Проверить ключ» использует поля формы; незаполненное поле подставляется из БД. «Обновить каталог» только по сохранённым в БД настройкам.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={
mode !== "live" ||
backendStatus !== true ||
evoBusy !== null ||
evoSaveBusy ||
!(
(evoBaseDraft.trim() || evo.baseUrl.trim()) &&
(evoKeyDraft.trim() || evo.secretConfigured)
)
}
onClick={async () => {
setEvoBusy("test")
setEvoTestResult(null)
const b = evoBaseDraft.trim()
const k = evoKeyDraft.trim()
let draft: EvoBgpTestDraft | undefined
if (b || k) {
draft = {}
if (b) draft.baseUrl = b
if (k) draft.apiKey = k
}
const r = await evo.testConnection(draft)
setEvoTestResult({ ok: r.ok, message: r.message })
setEvoBusy(null)
}}
>
{evoBusy === "test" ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Проверить ключ
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={mode !== "live" || backendStatus !== true || evoBusy !== null || evoSaveBusy || !evo.enabled}
onClick={async () => {
setEvoBusy("refresh")
await evo.refresh()
setEvoBusy(null)
}}
>
{evoBusy === "refresh" || evo.loading ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
Обновить каталог
</Button>
{evo.snapshot?.fetchedAt && (
<span className="text-xs text-muted-foreground">
Загружено: {new Date(evo.snapshot.fetchedAt).toLocaleString("ru-RU")}
</span>
)}
</div>
{evoTestResult && (
<p className={cn(
"text-xs",
evoTestResult.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive",
)}>
{evoTestResult.message}
</p>
)}
{evo.error && (
<p className="text-xs text-destructive">{evo.error}</p>
)}
</CardContent>
</Card>
</div>
)
@@ -1306,9 +1803,26 @@ export default function SettingsPage() {
<PageHeader
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
actions={
<Button size="sm" onClick={handleSave}>
{saved ? <CheckIcon className="size-4" /> : <SaveIcon className="size-4" />}
{saved ? "Сохранено!" : "Сохранить"}
<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>
}
/>