204 lines
7.7 KiB
TypeScript
204 lines
7.7 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import { detectWgConfigFormat, parseWgConfig, type WgParsedConfig } from "@/lib/wg-config"
|
||
import { UploadIcon } from "lucide-react"
|
||
|
||
type ServerOption = { id: string; name: string; host: string }
|
||
|
||
function WgImportSheet({
|
||
open,
|
||
onOpenChange,
|
||
servers,
|
||
busy,
|
||
error,
|
||
defaultServerId,
|
||
onImport,
|
||
}: {
|
||
open: boolean
|
||
onOpenChange: (v: boolean) => void
|
||
servers: ServerOption[]
|
||
busy?: boolean
|
||
error?: string | null
|
||
defaultServerId?: string
|
||
onImport: (args: {
|
||
serverId: string
|
||
content: string
|
||
format: "auto" | "rsc" | "conf"
|
||
dryRun: boolean
|
||
}) => Promise<void>
|
||
}) {
|
||
const [serverId, setServerId] = useState("")
|
||
const [content, setContent] = useState("")
|
||
const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto")
|
||
const [preview, setPreview] = useState<WgParsedConfig | null>(null)
|
||
const [parseError, setParseError] = useState<string | null>(null)
|
||
|
||
const detected = useMemo(
|
||
() => (content.trim() ? detectWgConfigFormat(content) : null),
|
||
[content],
|
||
)
|
||
|
||
const [context, setContext] = useState({ open: false, defaultServerId })
|
||
if (context.open !== open || context.defaultServerId !== defaultServerId) {
|
||
setContext({ open, defaultServerId })
|
||
if (open) setServerId(defaultServerId ?? "")
|
||
}
|
||
|
||
function runPreview() {
|
||
setParseError(null)
|
||
setPreview(null)
|
||
try {
|
||
setPreview(parseWgConfig(content, format))
|
||
} catch (e) {
|
||
setParseError(e instanceof Error ? e.message : "Ошибка разбора")
|
||
}
|
||
}
|
||
|
||
function onFile(file: File | null) {
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
setContent(String(reader.result ?? ""))
|
||
setPreview(null)
|
||
setParseError(null)
|
||
}
|
||
reader.readAsText(file)
|
||
}
|
||
|
||
return (
|
||
<Sheet
|
||
open={open}
|
||
onOpenChange={(v) => {
|
||
if (busy) return
|
||
if (v) {
|
||
setServerId(defaultServerId ?? "")
|
||
} else {
|
||
setContent("")
|
||
setPreview(null)
|
||
setParseError(null)
|
||
setServerId("")
|
||
}
|
||
onOpenChange(v)
|
||
}}
|
||
>
|
||
<SheetContent showCloseButton={!busy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
|
||
<SheetDescription>
|
||
Native .conf или MikroTik .rsc → применить на выбранный роутер.
|
||
В .conf без Name используется имя wg0. Заполните ключ и сети пиров;
|
||
совпадение имени остановит импорт.
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<fieldset disabled={busy} aria-busy={busy} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||
<FormField label="Сервер" required>
|
||
<select
|
||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||
aria-label="Сервер"
|
||
value={serverId}
|
||
onChange={(e) => setServerId(e.target.value)}
|
||
>
|
||
<option value="">Выберите сервер…</option>
|
||
{servers.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name} ({s.host})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FormField>
|
||
|
||
<FormField label="Формат">
|
||
<select
|
||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||
aria-label="Формат"
|
||
value={format}
|
||
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
|
||
>
|
||
<option value="auto">Авто{detected ? ` (${detected})` : ""}</option>
|
||
<option value="conf">Native WireGuard (.conf)</option>
|
||
<option value="rsc">MikroTik (.rsc)</option>
|
||
</select>
|
||
</FormField>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<SectionTitle>Содержимое</SectionTitle>
|
||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer w-fit">
|
||
<UploadIcon className="size-4" />
|
||
Загрузить файл
|
||
<input
|
||
type="file"
|
||
accept=".conf,.rsc,.txt,text/plain"
|
||
className="sr-only"
|
||
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
|
||
/>
|
||
</label>
|
||
<textarea
|
||
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
|
||
aria-label="Конфигурация WireGuard"
|
||
value={content}
|
||
onChange={(e) => {
|
||
setContent(e.target.value)
|
||
setPreview(null)
|
||
setParseError(null)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Button type="button" variant="outline" size="sm" onClick={runPreview} disabled={!content.trim()}>
|
||
Предпросмотр
|
||
</Button>
|
||
|
||
{parseError && <p role="alert" className="text-sm text-destructive">{parseError}</p>}
|
||
|
||
{preview && (
|
||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
|
||
<p className="font-medium">
|
||
{preview.format.toUpperCase()} · {preview.interface.name}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground font-mono">
|
||
port={preview.interface.listenPort ?? "—"} · mtu={preview.interface.mtu ?? "—"}
|
||
{preview.interface.address ? ` · ${preview.interface.address}` : ""}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">Пиров: {preview.peers.length}</p>
|
||
{preview.peers.slice(0, 5).map((p, i) => (
|
||
<p key={i} className="text-[11px] font-mono text-muted-foreground truncate">
|
||
{p.publicKey.slice(0, 16)}… → {p.allowedAddresses.join(", ")}
|
||
</p>
|
||
))}
|
||
</div>
|
||
)}
|
||
</fieldset>
|
||
|
||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
|
||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата импорта…</p>}
|
||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||
<div className="flex w-full gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||
Отмена
|
||
</SheetClose>
|
||
<Button
|
||
type="button"
|
||
className="flex-1"
|
||
disabled={!serverId || !content.trim() || busy}
|
||
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
|
||
>
|
||
{busy ? "Импорт…" : "Применить на роутер"}
|
||
</Button>
|
||
</div>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
export { WgImportSheet }
|