196 lines
11 KiB
TypeScript
196 lines
11 KiB
TypeScript
"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>
|
||
}
|