202 lines
7.5 KiB
TypeScript
202 lines
7.5 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 → применить на выбранный роутер
|
|
</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 }
|