feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
export type WgCreateFormState = {
|
||||
serverId: string
|
||||
name: string
|
||||
listenPort: string
|
||||
mtu: string
|
||||
comment: string
|
||||
address: string
|
||||
enabled: boolean
|
||||
showAdvanced: boolean
|
||||
peerEnabled: boolean
|
||||
peerPublicKey: string
|
||||
peerAllowedIps: string
|
||||
peerEndpoint: string
|
||||
peerKeepalive: string
|
||||
peerComment: string
|
||||
}
|
||||
|
||||
export const defaultWgCreateForm = (): WgCreateFormState => ({
|
||||
serverId: "",
|
||||
name: "",
|
||||
listenPort: "13231",
|
||||
mtu: "1420",
|
||||
comment: "",
|
||||
address: "",
|
||||
enabled: true,
|
||||
showAdvanced: false,
|
||||
peerEnabled: false,
|
||||
peerPublicKey: "",
|
||||
peerAllowedIps: "",
|
||||
peerEndpoint: "",
|
||||
peerKeepalive: "25",
|
||||
peerComment: "",
|
||||
})
|
||||
|
||||
type ServerOption = { id: string; name: string; host: string }
|
||||
|
||||
function WgCreateSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
busy,
|
||||
error,
|
||||
defaultServerId,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerOption[]
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
defaultServerId?: string
|
||||
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
|
||||
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const [context, setContext] = useState({ open: false, defaultServerId })
|
||||
if (context.open !== open || context.defaultServerId !== defaultServerId) {
|
||||
setContext({ open, defaultServerId })
|
||||
if (open) setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
||||
}
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
||||
}, [form.serverId, form.name, form.listenPort])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!busy) onOpenChange(v) }}>
|
||||
<SheetContent showCloseButton={!busy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
||||
<SheetDescription>
|
||||
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
|
||||
</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">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<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={form.serverId}
|
||||
onChange={(e) => set("serverId", 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="Имя интерфейса" required hint="Например wg-msk-spb">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="wg0"
|
||||
aria-label="Имя интерфейса" value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Listen port" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Listen port" value={form.listenPort}
|
||||
onChange={(e) => set("listenPort", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="MTU">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="MTU" value={form.mtu}
|
||||
onChange={(e) => set("mtu", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Комментарий">
|
||||
<Input
|
||||
aria-label="Комментарий" value={form.comment}
|
||||
onChange={(e) => set("comment", e.target.value)}
|
||||
placeholder="MSK → SPB overlay"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Включён</p>
|
||||
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
|
||||
</div>
|
||||
<Switch aria-label="Интерфейс включён" disabled={busy} checked={form.enabled} onCheckedChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||
>
|
||||
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
|
||||
Дополнительно
|
||||
</button>
|
||||
|
||||
{form.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="IP на интерфейсе" hint="IPv4 или IPv6 с префиксом, например fd00::1/64">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="IPv4/IPv6 с префиксом"
|
||||
aria-label="IP на интерфейсе" value={form.address}
|
||||
onChange={(e) => set("address", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Добавить первого пира</p>
|
||||
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
|
||||
</div>
|
||||
<Switch aria-label="Добавить первого пира" disabled={busy} checked={form.peerEnabled} onCheckedChange={(v) => set("peerEnabled", v)} />
|
||||
</div>
|
||||
|
||||
{form.peerEnabled && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
|
||||
<FormField label="Public key пира" required>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
aria-label="Public key пира" value={form.peerPublicKey}
|
||||
onChange={(e) => set("peerPublicKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Allowed IPs" required hint="Через запятую">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.210.0.2/32"
|
||||
aria-label="Allowed IPs пира" value={form.peerAllowedIps}
|
||||
onChange={(e) => set("peerAllowedIps", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" hint="host:port">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="1.2.3.4:13231"
|
||||
aria-label="Endpoint пира" value={form.peerEndpoint}
|
||||
onChange={(e) => set("peerEndpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Keepalive (сек)">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Keepalive пира" value={form.peerKeepalive}
|
||||
onChange={(e) => set("peerKeepalive", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Комментарий пира">
|
||||
<Input
|
||||
aria-label="Комментарий пира" value={form.peerComment}
|
||||
onChange={(e) => set("peerComment", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
{busy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание подтверждения создания…</p>}
|
||||
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => void onSubmit(form)}
|
||||
>
|
||||
{busy ? "Создание…" : "Создать туннель"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgCreateSheet }
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { resolveWgClientEndpoint } from "@mmapp/contracts/wireguard"
|
||||
import { selectWgExportPeer } from "@/lib/wg-export-peer"
|
||||
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
|
||||
import {
|
||||
CodeExportSheet,
|
||||
type CodeExportFormat,
|
||||
} from "@/components/reui-kit/code-export-sheet"
|
||||
import {
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
generatePeerClientConf,
|
||||
} from "@/lib/wg-config"
|
||||
|
||||
function WgExportSheet({
|
||||
open,
|
||||
iface,
|
||||
onClose,
|
||||
liveContent,
|
||||
liveBusy,
|
||||
onRequestLiveExport,
|
||||
initialTab = "rsc",
|
||||
peerId,
|
||||
serverHost,
|
||||
}: {
|
||||
open: boolean
|
||||
iface: WgIfaceWithServer | null
|
||||
onClose: () => void
|
||||
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
|
||||
liveBusy?: boolean
|
||||
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void
|
||||
initialTab?: "rsc" | "conf" | "peer"
|
||||
/** Selected peer for Peer .conf (defaults to first peer) */
|
||||
peerId?: string | null
|
||||
serverHost?: string
|
||||
}) {
|
||||
const formats = useMemo((): CodeExportFormat[] => {
|
||||
if (!iface) {
|
||||
return [
|
||||
{ id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
|
||||
{ id: "conf", label: ".conf", filename: "wg.conf", code: "" },
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer",
|
||||
filename: "wg-peer.conf",
|
||||
code: "",
|
||||
emptyMessage: "Интерфейс не выбран",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: iface.privateKey,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
})),
|
||||
}
|
||||
|
||||
const peer = selectWgExportPeer(iface.peers, peerId)
|
||||
|
||||
const localRsc = generateMikrotikRsc(base)
|
||||
const localConf = generateNativeConf(base)
|
||||
|
||||
let peerConf = ""
|
||||
let peerEmpty: string | undefined
|
||||
if (peerId != null && !peer) {
|
||||
peerEmpty = "Выбранный пир не найден. Обновите список и выберите пира заново."
|
||||
} else if (!iface.publicKey) {
|
||||
peerEmpty = "Нет public-key интерфейса для клиентского .conf"
|
||||
} else if (!peer) {
|
||||
peerEmpty = "Нет пиров для экспорта Peer .conf"
|
||||
} else {
|
||||
peerConf = generatePeerClientConf({
|
||||
peerAddress: peer.clientAddress,
|
||||
peerDns: peer.clientDns,
|
||||
serverPublicKey: iface.publicKey,
|
||||
endpoint: resolveWgClientEndpoint(peer.clientEndpoint, serverHost, iface.listenPort),
|
||||
persistentKeepalive: peer.persistentKeepalive ?? 25,
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "rsc",
|
||||
label: ".rsc",
|
||||
filename: `${iface.name}.rsc`,
|
||||
code: liveContent?.rsc ?? localRsc,
|
||||
isLive: Boolean(liveContent?.rsc),
|
||||
},
|
||||
{
|
||||
id: "conf",
|
||||
label: ".conf",
|
||||
filename: `${iface.name}.conf`,
|
||||
code: liveContent?.conf ?? localConf,
|
||||
isLive: Boolean(liveContent?.conf),
|
||||
},
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer",
|
||||
filename: `${iface.name}-peer.conf`,
|
||||
code: liveContent?.peerConf ?? peerConf,
|
||||
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
|
||||
isLive: Boolean(liveContent?.peerConf),
|
||||
},
|
||||
]
|
||||
}, [iface, liveContent, peerId, serverHost])
|
||||
|
||||
return (
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт WireGuard"
|
||||
description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
|
||||
formats={formats}
|
||||
beforeCode={<p className="text-xs text-muted-foreground">Peer .conf — шаблон для клиента. Заполните его приватный ключ, адрес и сети AllowedIPs. При NAT укажите доступный извне client-endpoint; endpoint удалённого пира здесь не используется.</p>}
|
||||
initialFormatId={initialTab}
|
||||
liveBusy={liveBusy}
|
||||
liveLabel="С роутера"
|
||||
onLiveFetch={
|
||||
onRequestLiveExport
|
||||
? (formatId) =>
|
||||
onRequestLiveExport(
|
||||
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgExportSheet }
|
||||
@@ -0,0 +1,203 @@
|
||||
"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 }
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
|
||||
|
||||
export type WgPeerFormState = {
|
||||
publicKey: string
|
||||
allowedIps: string
|
||||
endpoint: string
|
||||
keepalive: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
const emptyPeerForm = (): WgPeerFormState => ({
|
||||
publicKey: "",
|
||||
allowedIps: "",
|
||||
endpoint: "",
|
||||
keepalive: "25",
|
||||
comment: "",
|
||||
})
|
||||
|
||||
function WgPeerSheet({
|
||||
open,
|
||||
iface,
|
||||
busy,
|
||||
error,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
iface: WgIfaceWithServer | null
|
||||
busy?: boolean
|
||||
error?: string | null
|
||||
onOpenChange: (v: boolean) => void
|
||||
onSubmit: (form: WgPeerFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<WgPeerFormState>(emptyPeerForm)
|
||||
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const [context, setContext] = useState({ open: false, iface })
|
||||
if (context.open !== open || context.iface !== iface) {
|
||||
setContext({ open, iface })
|
||||
if (open) setForm(emptyPeerForm())
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (busy) return
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<SheetContent showCloseButton={!busy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Добавить пира</SheetTitle>
|
||||
<SheetDescription>
|
||||
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
|
||||
</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-4">
|
||||
<SectionTitle>Параметры пира</SectionTitle>
|
||||
<FormField label="Public key" required>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
aria-label="Public key" value={form.publicKey}
|
||||
onChange={(e) => set("publicKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Allowed IPs" required hint="Через запятую">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.210.0.2/32"
|
||||
aria-label="Allowed IPs" value={form.allowedIps}
|
||||
onChange={(e) => set("allowedIps", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" hint="host:port">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="1.2.3.4:13231"
|
||||
aria-label="Endpoint" value={form.endpoint}
|
||||
onChange={(e) => set("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Keepalive (сек)">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Keepalive (сек)" value={form.keepalive}
|
||||
onChange={(e) => set("keepalive", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input aria-label="Комментарий" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
{busy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание подтверждения создания…</p>}
|
||||
{error && <p role="alert" className="px-6 text-sm text-destructive">{error}</p>}
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
|
||||
onClick={() => void onSubmit(form)}
|
||||
>
|
||||
{busy ? "Сохранение…" : "Добавить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgPeerSheet }
|
||||
Reference in new issue
Block a user