Files
MikrotikManager/app/(main)/settings/page.tsx
T

373 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useState } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
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 { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useDataSource } from "@/lib/data-source"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
CheckIcon,
AlertCircleIcon,
LoaderCircleIcon,
DownloadIcon, UploadIcon, XIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
import { toast } from "sonner"
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>
)
}
function DatabaseRestoreConfirm({
open,
filename,
busy,
onConfirm,
onCancel,
}: {
open: boolean
filename: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<AlertDialogDescription>
Файл: {filename}. Текущие данные PostgreSQL на бекенде будут полностью заменены.
Рекомендуется сначала скачать актуальный бэкап.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={onConfirm}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const [section, setSection] = useState<NavSection>("Общие")
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)
const systemDbAvailable = mode === "live" && backendStatus === true
const handleSystemDatabaseBackup = useCallback(async () => {
if (!systemDbAvailable) return
setDbBackupBusy(true)
try {
const { blob, filename } = await downloadSystemDatabaseBackup(backendUrl)
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success("Бэкап базы приложения скачан")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось создать бэкап")
} finally {
setDbBackupBusy(false)
}
}, [backendUrl, systemDbAvailable])
const handleSystemDatabaseRestoreConfirm = useCallback(async () => {
if (!dbRestoreFile || !systemDbAvailable) return
setDbRestoreBusy(true)
try {
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
toast.success("База приложения восстановлена")
setDbRestoreFile(null)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
} finally {
setDbRestoreBusy(false)
}
}, [backendUrl, dbRestoreFile, systemDbAvailable])
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" />
Не проверялось
</span>
)}
{backendStatus === true && (
<span className="flex items-center gap-1.5 text-xs text-emerald-600 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-600 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>
<OpsPanel
title="База данных приложения"
description="Резервная копия и восстановление базы приложения. Для сброса потоков откройте настройки NetFlow."
contentClassName="divide-y px-5"
>
{!systemDbAvailable && (
<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>Доступно только в live-режиме при доступном бекенде.</span>
</div>
)}
<SettingRow
label="Скачать бэкап"
description="Консистентный дамп PostgreSQL (pg_dump -Fc)"
>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => { void handleSystemDatabaseBackup() }}
>
{dbBackupBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}
{dbBackupBusy ? "Подготовка…" : "Скачать"}
</Button>
</SettingRow>
<SettingRow
label="Восстановить из файла"
description="Полностью заменяет текущую базу PostgreSQL (pg_restore)"
>
<div className="flex flex-col items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
onClick={() => setDbRestoreDialogOpen(true)}
>
<UploadIcon className="size-4" />
Выбрать файл
</Button>
{dbRestoreFile && (
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
)}
</div>
</SettingRow>
<SettingRow
label="Сбросить данные NetFlow"
description="Проверка состояния и подтверждение сброса — в разделе сбора данных"
>
{systemDbAvailable ? <Link href="/data-collection#netflow" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть NetFlow</Link> : <Button size="sm" variant="outline" disabled>Открыть NetFlow</Button>}
</SettingRow>
</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 <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>
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
onCancel={() => {
if (dbRestoreBusy) return
setDbRestoreFile(null)
}}
/>
)}
<FileImportDialog
open={dbRestoreDialogOpen}
onOpenChange={setDbRestoreDialogOpen}
title="Восстановление базы данных"
description="Выберите файл pg_dump custom (.dump) — текущая база будет полностью заменена"
accept=".dump,.backup,application/octet-stream"
onImport={async (files) => {
const file = files[0]
if (!file) return
setDbRestoreFile(file)
}}
/>
</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>
}