Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
192 lines
6.8 KiB
TypeScript
192 lines
6.8 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, 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,
|
|
defaultServerId,
|
|
onImport,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (v: boolean) => void
|
|
servers: ServerOption[]
|
|
busy?: boolean
|
|
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],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setServerId(defaultServerId ?? "")
|
|
}, [open, 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 (v) {
|
|
setServerId(defaultServerId ?? "")
|
|
} else {
|
|
setContent("")
|
|
setPreview(null)
|
|
setParseError(null)
|
|
setServerId("")
|
|
}
|
|
onOpenChange(v)
|
|
}}
|
|
>
|
|
<SheetContent side="right" className="w-full 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>
|
|
|
|
<div className="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]"
|
|
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"
|
|
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 …"}
|
|
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 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>
|
|
)}
|
|
</div>
|
|
|
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
|
|
<div className="flex w-full gap-2">
|
|
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
|
Отмена
|
|
</SheetClose>
|
|
<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 }
|