Files

250 lines
10 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { FormField } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { FlowHostPanel } from "./flow-host-panel"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
InfoIcon,
} from "lucide-react"
function FlowOverlaySheet({
open,
onOpenChange,
servers,
backendUrl,
onDone,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerRead[]
backendUrl: string
onDone?: (result: TrafficFlowOverlayResult) => void
}) {
const jumpHosts = useMemo(
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
[servers],
)
const [serverId, setServerId] = useState("")
const [endpoint, setEndpoint] = useState("")
const [busy, setBusy] = useState(false)
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
const [copied, setCopied] = useState(false)
const [tab, setTab] = useState("linux")
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
const [resetContext, setResetContext] = useState({ open: false, jumpHosts })
if (resetContext.open !== open || resetContext.jumpHosts !== jumpHosts) {
setResetContext({ open, jumpHosts })
if (open) {
setResult(null)
setCopied(false)
setTab("linux")
setDisableGreFastPath(false)
const first = jumpHosts[0]
const nextId = first ? String(first.id) : ""
setServerId(nextId)
setEndpoint(first?.host ?? "")
}
}
function handleServerChange(id: string) {
setServerId(id)
const selected = jumpHosts.find((s) => String(s.id) === id)
if (selected) setEndpoint(selected.host)
}
const formats = result?.hostFiles ?? []
const active = formats.find((f) => f.id === tab) ?? formats[0]
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
const selectedLabel = selectedHost
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
: "Выберите сервер…"
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
async function handleSubmit() {
if (!serverId || !endpoint.trim()) return
setBusy(true)
try {
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim(), {
disableGreFastPath,
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
if (res.ok) toast.success(`wg-flow на ${res.address}`)
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
} finally {
setBusy(false)
}
}
function handleCopy() {
const code = active?.code ?? ""
if (!code) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
toast.success("Скопировано")
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
Подключить jump-host
</SheetTitle>
<SheetDescription>
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<FormField label="Jump-host" required>
<Select
value={serverId || undefined}
onValueChange={(v) => handleServerChange(String(v ?? ""))}
>
<SelectTrigger aria-label="Jump-host" className="h-9 w-full min-w-0">
<SelectValue>{selectedLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" className="min-w-(--anchor-width)">
{jumpHosts.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="Публичный IP или DNS jump-host"
required
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
>
<Input aria-label="Публичный IP или DNS jump-host"
className="font-mono"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="jh.example.com"
autoComplete="off"
/>
</FormField>
<FormField
label="GRE slow-path (IPFIX inner)"
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
>
<div className="flex items-center gap-2">
<Switch aria-label="Выключить FastPath на GRE" checked={disableGreFastPath} onCheckedChange={setDisableGreFastPath} />
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
</div>
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
<InfoIcon />
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
<AlertDescription>
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
</AlertDescription>
</Alert>
<FlowHostPanel backendUrl={backendUrl} />
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
<span>{s}</span>
</li>
))}
</ul>
{formats.length > 0 && active ? (
<div className="flex min-h-0 flex-col gap-3">
<Tabs
value={tab}
onValueChange={(v) => {
setTab(String(v))
setCopied(false)
}}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
</ScrollArea>
</FramePanel>
</Frame>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => downloadText(active.filename, active.code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
<Button type="button" size="sm" onClick={handleCopy}>
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</div>
) : null}
</div>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
Закрыть
</SheetClose>
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
{busy ? "Подключение…" : "Подключить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { FlowOverlaySheet }