feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { useTheme } from "@/components/theme-provider"
import { SystemDatabaseSection } from "@/components/settings/system-database-section"
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
import { OpsPanel } from "@/components/ops-panel"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
import {
CheckIcon,
AlertCircleIcon,
XIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
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 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="min-w-0 max-w-full">{children}</div>
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const [section, setSection] = useState<NavSection>("Общие")
const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
const { theme, setTheme } = useTheme()
const renderContent = () => {
// ── Общие ──
if (section === "Общие") return (
<div className="flex flex-col gap-4">
{/* ── Источник данных ── */}
<OpsPanel
title="Источник данных"
description={
mockModeAvailable
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
: "Источник живых данных задан конфигурацией приложения."
}
contentClassName="divide-y px-5"
>
{/* mode toggle */}
{mockModeAvailable ? (
<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} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
className={cn(
"px-3 text-xs transition-colors border-input",
i === 0 && "border-r",
mode === v
? "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-primary px-3 text-xs text-primary-foreground">
Живые
</span>
</SettingRow>
)}
{/* backend URL — visible in both modes so user can configure before switching */}
<SettingRow
label="URL бекенда"
description={
backendUrlLocked
? "Адрес подключения задан конфигурацией приложения."
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
}
>
<BackendUrlField key={backendUrl} />
</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" />
{backendChecking ? "Проверка…" : "Не проверялось"}
</span>
)}
{backendStatus === true && (
<span className="flex items-center gap-1.5 text-xs text-emerald-700 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-700 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
</div>
)}
</OpsPanel>
<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>
<SystemDatabaseSection />
<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 <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: "Настройки" }]}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
{/* 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} 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",
)}>
{s}
</button>
))}
</nav>
{/* content */}
<div className="min-w-0">{renderContent()}</div>
</div>
</div>
</div>
)
}
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>
}