Compare commits

..
2 Commits
Author SHA1 Message Date
Denozordec 6123660346 feat(terminal, wireguard): enhance UI components and functionality
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.
2026-09-06 01:23:40 +07:00
Denozordec b680f882cc feat(ui): enhance CodeExportSheet and WgExportSheet components
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00
12 changed files with 1254 additions and 334 deletions
+147 -98
View File
@@ -4,11 +4,30 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag" import type { ServerStatus } from "@/lib/data"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
import { import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, Frame,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
ServerTileRail,
type ServerTileItem,
} from "@/components/server-tile-rail"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -437,6 +456,7 @@ export default function TerminalPage() {
const [liveServers, setLiveServers] = useState<TermServer[]>([]) const [liveServers, setLiveServers] = useState<TermServer[]>([])
const [serversLoading, setServersLoading] = useState(false) const [serversLoading, setServersLoading] = useState(false)
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
const [railOpen, setRailOpen] = useState(false)
// Load servers from backend when in live mode // Load servers from backend when in live mode
useEffect(() => { useEffect(() => {
@@ -492,6 +512,79 @@ export default function TerminalPage() {
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}` const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
const railItems = useMemo<ServerTileItem[]>(() => {
return termServers.map((s) => ({
id: s.uid,
name: s.name,
country: s.country || undefined,
status: (s.status ?? undefined) as ServerStatus | undefined,
enabled: s.enabled,
selectable: s.enabled && s.status !== "offline",
title: [s.name, s.host].filter(Boolean).join(" · "),
}))
}, [termServers])
function handleSelectServer(id: string) {
setSelectedUid(id)
setRefreshKey((k) => k + 1)
setRailOpen(false)
}
const railHeaderRight = isLive
? serversLoading
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
: <Badge variant="success-light" size="xs">LIVE</Badge>
: undefined
const rail = (
<ServerTileRail
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
headerRight={railHeaderRight}
className="min-h-0 flex-1"
/>
)
function QuickCmds() {
return (
<Frame dense spacing="sm" className="min-h-0 shrink-0">
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
<FrameHeader className="border-b px-3 py-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Быстрые команды
</FrameTitle>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
type="button"
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</ScrollArea>
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-info">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>}
</FrameFooter>
</FramePanel>
</Frame>
)
}
function injectCommand(cmd: string) { function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active") const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return if (!el) return
@@ -502,108 +595,42 @@ export default function TerminalPage() {
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
<PageHeader <PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]} crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={ actions={
<Button variant="outline" size="sm" onClick={() => { <>
setRefreshKey(k => k + 1) <Button
if (isLive) setSelectedUid("") size="sm"
}}> variant="outline"
<RefreshCwIcon className="size-4" />Переподключить className="md:hidden"
</Button> onClick={() => setRailOpen(true)}
>
<ServerIcon className="size-4" />
{selected?.name ?? "Сервер"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setRefreshKey((k) => k + 1)
if (isLive) setSelectedUid("")
}}
>
<RefreshCwIcon className="size-4" />
Переподключить
</Button>
</>
} }
/> />
<div className="flex-1 overflow-hidden p-6"> <div className="flex min-h-0 flex-1">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full"> <aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
{rail}
<QuickCmds />
</aside>
{/* ── sidebar ── */} <div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
{selected ? ( {selected ? (
<Terminal <Terminal
key={termKey} key={termKey}
@@ -612,12 +639,34 @@ export default function TerminalPage() {
backendUrl={backendUrl} backendUrl={backendUrl}
/> />
) : ( ) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono"> <div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"} {serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div> </div>
)} )}
</div> </div>
</div> </div>
<Sheet open={railOpen} onOpenChange={setRailOpen}>
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
<SheetHeader className="px-1 pt-1">
<SheetTitle>Серверы</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<ServerTileRail
items={railItems}
selectedId={selected?.uid ?? selectedUid}
onSelect={handleSelectServer}
showAll={false}
showCount={false}
showType={false}
showHeader={false}
headerRight={railHeaderRight}
className="min-h-0 flex-1"
/>
<QuickCmds />
</div>
</SheetContent>
</Sheet>
</div> </div>
) )
} }
+428 -139
View File
@@ -10,6 +10,10 @@ import {
WireguardDataGrid, WireguardDataGrid,
type WgIfaceWithServer, type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid" } from "@/components/data-grids/wireguard-data-grid"
import {
WireguardPeersGrid,
type WgPeerRow,
} from "@/components/data-grids/wireguard-peers-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Alert, Alert,
@@ -18,6 +22,24 @@ import {
} from "@/components/reui/alert" } from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { import {
@@ -35,12 +57,25 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet" import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet" import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet" import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
import {
ALL_SERVERS_ID,
ServerTileRail,
type ServerTileItem,
} from "@/components/server-tile-rail"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
type WgStatusFilter = "all" | "up" | "down"
type WgFailure = { serverId: string; serverName?: string; error: string }
type PendingDelete =
| { kind: "iface"; iface: WgIfaceWithServer }
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
function collectMockInterfaces(): WgIfaceWithServer[] { function collectMockInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = [] const result: WgIfaceWithServer[] = []
for (const srv of mockServers) { for (const srv of mockServers) {
@@ -98,7 +133,11 @@ interface BackendServer {
name: string name: string
host: string host: string
country: string country: string
type?: Server["type"]
enabled: boolean enabled: boolean
status?: Server["status"]
latency?: number | null
asn?: string
} }
function mapBackendServer(s: BackendServer): Server { function mapBackendServer(s: BackendServer): Server {
@@ -110,11 +149,11 @@ function mapBackendServer(s: BackendServer): Server {
os: "—", os: "—",
site: "", site: "",
country: s.country || "UN", country: s.country || "UN",
asn: "", asn: s.asn ?? "",
type: "exit-node", type: s.type ?? "exit-node",
enabled: s.enabled, enabled: s.enabled,
status: "online", status: s.status ?? "online",
latency: null, latency: s.latency ?? null,
sessions: 0, sessions: 0,
} }
} }
@@ -130,15 +169,24 @@ function parseEndpoint(endpoint: string): { address?: string; port?: number } {
} }
} }
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
}
export default function WireGuardPage() { export default function WireGuardPage() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([]) const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([]) const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<WgFailure[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [workspaceTab, setWorkspaceTab] = useState<WgWorkspaceTab>("interfaces")
const [statusFilter, setStatusFilter] = useState<WgStatusFilter>("all")
const [railOpen, setRailOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
@@ -146,6 +194,7 @@ export default function WireGuardPage() {
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc") const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null) const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null) const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
conf?: string conf?: string
@@ -163,6 +212,7 @@ export default function WireGuardPage() {
]) ])
setLiveIfaces(wg.interfaces.map(dtoToRow)) setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(wg.failures ?? [])
if (wg.failures?.length) { if (wg.failures?.length) {
toast.warning( toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
@@ -171,6 +221,7 @@ export default function WireGuardPage() {
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([]) setLiveIfaces([])
setFailures([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -181,6 +232,7 @@ export default function WireGuardPage() {
queueMicrotask(() => { queueMicrotask(() => {
setLiveIfaces([]) setLiveIfaces([])
setLiveServers([]) setLiveServers([])
setFailures([])
}) })
return return
} }
@@ -192,10 +244,35 @@ export default function WireGuardPage() {
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces() const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => { const effectiveServerId =
if (!search) return displayIfaces selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
? selectedServerId
: ALL_SERVERS_ID
const scopedIfaces = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
}, [displayIfaces, effectiveServerId])
const statusCounts = useMemo(() => {
let up = 0
let down = 0
for (const iface of scopedIfaces) {
if (iface.status === "up") up += 1
else down += 1
}
return { all: scopedIfaces.length, up, down }
}, [scopedIfaces])
const statusFiltered = useMemo(() => {
if (statusFilter === "all") return scopedIfaces
return scopedIfaces.filter((i) => i.status === statusFilter)
}, [scopedIfaces, statusFilter])
const filteredIfaces = useMemo(() => {
if (!search) return statusFiltered
const q = search.toLowerCase() const q = search.toLowerCase()
return displayIfaces.filter( return statusFiltered.filter(
(i) => (i) =>
i.name.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) ||
i.serverName.toLowerCase().includes(q) || i.serverName.toLowerCase().includes(q) ||
@@ -205,14 +282,47 @@ export default function WireGuardPage() {
(p.endpoint ?? "").includes(q), (p.endpoint ?? "").includes(q),
), ),
) )
}, [displayIfaces, search]) }, [statusFiltered, search])
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0) const peerRows = useMemo<WgPeerRow[]>(() => {
const onlinePeers = displayIfaces.reduce( const q = search.toLowerCase()
const rows: WgPeerRow[] = []
for (const iface of scopedIfaces) {
iface.peers.forEach((peer, index) => {
const id = peerRowId(iface, peer, index)
if (q) {
const hay = [
peer.publicKey,
peer.name ?? "",
peer.endpoint ?? "",
peer.allowedIps.join(" "),
iface.name,
iface.serverName,
].join(" ").toLowerCase()
if (!hay.includes(q)) return
}
rows.push({
...peer,
id,
ifaceId: iface.id,
ifaceName: iface.name,
serverId: iface.serverId,
serverName: iface.serverName,
serverCountry: iface.serverCountry,
})
})
}
return rows
}, [scopedIfaces, search])
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = scopedIfaces.reduce(
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, (s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
0, 0,
) )
const upIfaces = displayIfaces.filter((i) => i.status === "up").length const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
const compactServer = effectiveServerId !== ALL_SERVERS_ID
const sheetServerId = compactServer ? effectiveServerId : undefined
const serverOptions = displayServers.map((s) => ({ const serverOptions = displayServers.map((s) => ({
id: s.id, id: s.id,
@@ -220,6 +330,33 @@ export default function WireGuardPage() {
host: s.host, host: s.host,
})) }))
const railItems = useMemo<ServerTileItem[]>(() => {
const counts = new Map<string, number>()
for (const iface of displayIfaces) {
counts.set(iface.serverId, (counts.get(iface.serverId) ?? 0) + 1)
}
return displayServers.map((s) => ({
id: s.id,
name: s.name,
country: s.country,
status: s.status,
type: s.type,
count: counts.get(s.id) ?? 0,
enabled: s.enabled,
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
}))
}, [displayServers, displayIfaces])
const selectedLabel =
effectiveServerId === ALL_SERVERS_ID
? "Все серверы"
: (displayServers.find((s) => s.id === effectiveServerId)?.name ?? "Сервер")
function handleSelectServer(id: string) {
setSelectedServerId(id)
setRailOpen(false)
}
async function handleCreate(form: WgCreateFormState) { async function handleCreate(form: WgCreateFormState) {
if (!isLive) { if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме") toast.info("Создание на роутер доступно только в live-режиме")
@@ -308,15 +445,26 @@ export default function WireGuardPage() {
} }
} }
async function handleDelete(iface: WgIfaceWithServer) { async function confirmDelete() {
if (!isLive || !iface.rosId) { if (!pendingDelete) return
if (!isLive) {
toast.info("Доступно только в live-режиме") toast.info("Доступно только в live-режиме")
setPendingDelete(null)
return return
} }
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try { try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId) if (pendingDelete.kind === "iface") {
toast.success("Удалено") if (!pendingDelete.iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
toast.success("Удалено")
} else {
await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId)
toast.success("Пир удалён")
}
setPendingDelete(null)
await loadLive() await loadLive()
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления") toast.error(e instanceof Error ? e.message : "Ошибка удаления")
@@ -354,21 +502,6 @@ export default function WireGuardPage() {
} }
} }
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
if (!isLive) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm("Удалить пира?")) return
try {
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
toast.success("Пир удалён")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return if (!exportIface || !isLive) return
setExportBusy(true) setExportBusy(true)
@@ -403,12 +536,37 @@ export default function WireGuardPage() {
setExportIface(iface) setExportIface(iface)
} }
const emptyCreateAction = (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
)
const rail = (
<ServerTileRail
items={railItems}
selectedId={effectiveServerId}
onSelect={handleSelectServer}
allCount={displayIfaces.length}
/>
)
return ( return (
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
<PageHeader <PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]} crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={ actions={
<> <>
<Button
size="sm"
variant="outline"
className="md:hidden"
onClick={() => setRailOpen(true)}
>
<ServerIcon className="size-4" />
{selectedLabel}
</Button>
{isLive && ( {isLive && (
<Button <Button
size="sm" size="sm"
@@ -432,123 +590,227 @@ export default function WireGuardPage() {
} }
/> />
<div className="flex-1 overflow-y-auto p-6"> <div className="flex min-h-0 flex-1">
<div className="flex flex-col gap-5"> <aside className="hidden min-h-0 w-60 shrink-0 p-3 pr-0 md:flex">
<KpiStatGrid {rail}
aria-label="Сводка WireGuard" </aside>
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: displayIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
<Alert variant="info"> <div className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
<InfoIcon /> <div className="flex flex-col gap-4">
<AlertTitle>WireGuard live-интеграция RouterOS 7.x</AlertTitle> <KpiStatGrid
<AlertDescription> aria-label="Сводка WireGuard"
{isLive items={[
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера." {
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."} id: "ifaces",
</AlertDescription> label: "Интерфейсов",
</Alert> value: scopedIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
<DataPageCard> iconClassName: "text-muted-foreground",
<DataPageToolbar },
search={search} {
onSearchChange={setSearch} id: "up",
searchPlaceholder="Поиск по имени, серверу, IP…" label: "Активных (UP)",
countLabel={`${filtered.length} интерфейсов`} value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/> />
<WireguardDataGrid
interfaces={filtered}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4"> {failures.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <Alert variant="destructive">
{[ <AlertCircleIcon />
{ <AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
title: "Создать интерфейс", <AlertDescription>
lines: [ {failures.map((f) => f.serverName ?? f.serverId).join(", ")}
"/interface wireguard add \\", </AlertDescription>
" name=wg0 \\", </Alert>
" listen-port=13231 \\", ) : null}
" mtu=1420",
], {!isLive ? (
}, <Alert variant="info">
{ <InfoIcon />
title: "Добавить пира", <AlertTitle>Mock-режим</AlertTitle>
lines: [ <AlertDescription>
"/interface wireguard peers add \\", Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
" interface=wg0 \\", </AlertDescription>
' public-key="<ключ>" \\', </Alert>
" allowed-address=10.0.0.2/32 \\", ) : null}
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25", <Tabs
], value={workspaceTab}
}, onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
{ className="gap-3"
title: "Назначить IP", >
lines: [ <TabsList variant="line">
"/ip address add \\", <TabsTrigger value="interfaces" className="gap-1.5">
" address=10.210.0.1/30 \\", <ShieldCheckIcon className="size-3.5" />
" interface=wg0", Интерфейсы
"", </TabsTrigger>
"# Статус:", <TabsTrigger value="peers" className="gap-1.5">
"/interface wireguard print", <UsersIcon className="size-3.5" />
], Пиры
}, </TabsTrigger>
].map((b) => ( <TabsTrigger value="cli" className="gap-1.5">
<div key={b.title}> <CodeXmlIcon className="size-3.5" />
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide"> CLI
{b.title} </TabsTrigger>
</p> </TabsList>
<pre className="bg-muted rounded-md p-2.5 text-muted-foreground text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")} <TabsContent value="interfaces" className="mt-0 outline-none">
</pre> <DataPageCard>
</div> <DataPageToolbar
))} search={search}
</div> onSearchChange={setSearch}
</OpsPanel> searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filteredIfaces.length} интерфейсов`}
segmented={{
value: statusFilter,
onChange: setStatusFilter,
options: [
{ value: "all", label: "Все", count: statusCounts.all },
{ value: "up", label: "UP", count: statusCounts.up },
{ value: "down", label: "DOWN", count: statusCounts.down },
],
}}
/>
<WireguardDataGrid
interfaces={filteredIfaces}
compactServer={compactServer}
emptyAction={emptyCreateAction}
onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={(iface) => setPendingDelete({ kind: "iface", iface })}
onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="peers" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по ключу, IP, endpoint…"
countLabel={`${peerRows.length} пиров`}
/>
<WireguardPeersGrid
peers={peerRows}
compactServer={compactServer}
emptyAction={
scopedIfaces.length === 1 ? (
<Button size="sm" onClick={() => setPeerIface(scopedIfaces[0])}>
<PlusIcon className="size-4" />
Добавить пира
</Button>
) : emptyCreateAction
}
onDeletePeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
setPendingDelete({ kind: "peer", iface, peerId: row.id })
}}
onExportPeer={(row) => {
const iface = scopedIfaces.find((i) => i.id === row.ifaceId)
if (!iface) return
openExport(iface, "peer", row.id)
}}
/>
</DataPageCard>
</TabsContent>
<TabsContent value="cli" className="mt-0 outline-none">
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
{b.title}
</p>
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</TabsContent>
</Tabs>
</div>
</div> </div>
</div> </div>
<Sheet open={railOpen} onOpenChange={setRailOpen}>
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
<SheetHeader className="px-1 pt-1">
<SheetTitle>Серверы</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1">
<ServerTileRail
items={railItems}
selectedId={effectiveServerId}
onSelect={handleSelectServer}
allCount={displayIfaces.length}
showHeader={false}
className="h-full"
/>
</div>
</SheetContent>
</Sheet>
<WgCreateSheet <WgCreateSheet
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy} busy={busy}
onSubmit={handleCreate} onSubmit={handleCreate}
/> />
@@ -556,6 +818,7 @@ export default function WireGuardPage() {
open={importOpen} open={importOpen}
onOpenChange={setImportOpen} onOpenChange={setImportOpen}
servers={serverOptions} servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy} busy={busy}
onImport={handleImport} onImport={handleImport}
/> />
@@ -581,6 +844,32 @@ export default function WireGuardPage() {
liveBusy={exportBusy} liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined} onRequestLiveExport={isLive ? handleLiveExport : undefined}
/> />
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "iface"
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.`
: pendingDelete
? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => void confirmDelete()}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
+20 -9
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo } from "react" import { useMemo, type ReactNode } from "react"
import { import {
type ColumnDef, type ColumnDef,
getCoreRowModel, getCoreRowModel,
@@ -48,6 +48,8 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps { interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[] interfaces: WgIfaceWithServer[]
compactServer?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void onToggle?: (iface: WgIfaceWithServer) => void
@@ -58,6 +60,8 @@ interface WireguardDataGridProps {
function WireguardDataGrid({ function WireguardDataGrid({
interfaces, interfaces,
compactServer = false,
emptyAction,
onExport, onExport,
onAddPeer, onAddPeer,
onToggle, onToggle,
@@ -71,7 +75,11 @@ function WireguardDataGrid({
id: "name", id: "name",
accessorKey: "name", accessorKey: "name",
header: ({ column }) => ( header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" /> <DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const iface = row.original const iface = row.original
@@ -94,10 +102,12 @@ function WireguardDataGrid({
/> />
<span className="font-mono font-semibold text-sm">{iface.name}</span> <span className="font-mono font-semibold text-sm">{iface.name}</span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono"> {!compactServer ? (
<Flag code={iface.serverCountry || "UN"} size={12} /> <div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
{iface.serverName} <Flag code={iface.serverCountry || "UN"} size={12} />
</div> {iface.serverName}
</div>
) : null}
<p className="sr-only"> <p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров {onlinePeers}/{iface.peers.length} пиров
</p> </p>
@@ -106,7 +116,7 @@ function WireguardDataGrid({
) )
}, },
meta: { meta: {
headerTitle: "Интерфейс / Сервер", headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST, headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => ( expandedContent: (row: WgIfaceWithServer) => (
@@ -256,7 +266,7 @@ function WireguardDataGrid({
}, },
}, },
], ],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer], [compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
) )
const table = useReactTable({ const table = useReactTable({
@@ -274,7 +284,8 @@ function WireguardDataGrid({
<EmptyState <EmptyState
icon={<ShieldCheckIcon className="size-4" />} icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов" title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск" description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16" className="border-0 py-16"
/> />
) )
@@ -74,18 +74,18 @@ function WireGuardPeersDetail({
<span <span
className={cn( className={cn(
"font-mono text-[11px] whitespace-nowrap", "font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground", peer.latestHandshake ? "text-success" : "text-muted-foreground",
)} )}
> >
{peer.latestHandshake ?? "нет рукопожатия"} {peer.latestHandshake ?? "нет рукопожатия"}
</span> </span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap"> <div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" /> <ArrowDownIcon className="size-3 text-success" />
{fmtBytes(peer.transferRx)} {fmtBytes(peer.transferRx)}
</span> </span>
<span className="flex items-center gap-0.5"> <span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" /> <ArrowUpIcon className="size-3 text-info" />
{fmtBytes(peer.transferTx)} {fmtBytes(peer.transferTx)}
</span> </span>
</div> </div>
@@ -0,0 +1,268 @@
"use client"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
UsersIcon,
} from "lucide-react"
export interface WgPeerRow extends WireGuardPeer {
id: string
ifaceId: string
ifaceName: string
serverId: string
serverName: string
serverCountry: string
}
interface WireguardPeersGridProps {
peers: WgPeerRow[]
compactServer?: boolean
emptyAction?: ReactNode
onDeletePeer?: (row: WgPeerRow) => void
onExportPeer?: (row: WgPeerRow) => void
}
function WireguardPeersGrid({
peers,
compactServer = false,
emptyAction,
onDeletePeer,
onExportPeer,
}: WireguardPeersGridProps) {
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
const cols: ColumnDef<WgPeerRow>[] = [
{
id: "peer",
accessorKey: "publicKey",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
),
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex min-w-0 items-center gap-1.5">
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs" title={peer.publicKey}>
{peer.name || truncKey(peer.publicKey)}
</span>
</div>
)
},
meta: {
headerTitle: "Пир",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "iface",
accessorKey: "ifaceName",
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.ifaceName}</span>
),
meta: {
headerTitle: "Интерфейс",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
]
if (!compactServer) {
cols.push({
id: "server",
accessorKey: "serverName",
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
cell: ({ row }) => (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
<Flag code={row.original.serverCountry || "UN"} size={12} />
{row.original.serverName}
</div>
),
meta: {
headerTitle: "Сервер",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
})
}
cols.push(
{
id: "allowedIps",
accessorFn: (row) => row.allowedIps.join(", "),
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
cell: ({ row }) => (
<span className="block truncate font-mono text-xs text-muted-foreground">
{row.original.allowedIps.join(", ") || "—"}
</span>
),
meta: {
headerTitle: "Allowed IPs",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "handshake",
accessorKey: "latestHandshake",
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
cell: ({ row }) => (
<span
className={cn(
"whitespace-nowrap font-mono text-[11px]",
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
)}
>
{row.original.latestHandshake ?? "нет рукопожатия"}
</span>
),
meta: {
headerTitle: "Handshake",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "transfer",
header: () => (
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
),
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(row.original.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(row.original.transferTx)}
</span>
</div>
),
meta: {
headerTitle: "RX / TX",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "endpoint",
accessorKey: "endpoint",
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
cell: ({ row }) => (
<span className="font-mono text-[11px] text-muted-foreground">
{row.original.endpoint ?? "—"}
</span>
),
meta: {
headerTitle: "Endpoint",
headerClassName: DATA_GRID_CELL_PAD,
cellClassName: DATA_GRID_CELL_PAD,
},
},
{
id: "actions",
header: () => <span className="sr-only">Действия</span>,
enableSorting: false,
size: 72,
cell: ({ row }) => {
const peer = row.original
return (
<div className="flex justify-end gap-0.5">
{onExportPeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={() => onExportPeer(peer)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
) : null}
{onDeletePeer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={() => onDeletePeer(peer)}
>
<Trash2Icon className="size-3.5" />
</Button>
) : null}
</div>
)
},
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
)
return cols
}, [compactServer, onDeletePeer, onExportPeer])
const table = useReactTable({
data: peers,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
})
if (peers.length === 0) {
return (
<EmptyState
icon={<UsersIcon className="size-4" />}
title="Нет пиров"
description="Добавьте пира к интерфейсу или сбросьте поиск"
action={emptyAction}
className="border-0 py-16"
/>
)
}
return (
<DataGridShell
table={table}
recordCount={peers.length}
tableClassNames={{
headerRow: "border-b border-border",
bodyRow: cn("group/row"),
}}
/>
)
}
export { WireguardPeersGrid, type WireguardPeersGridProps }
+106 -68
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react" import { useEffect, useMemo, useState, type ReactNode } from "react"
import { import {
Alert, Alert,
AlertAction,
AlertDescription, AlertDescription,
AlertTitle, AlertTitle,
} from "@/components/reui/alert" } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -19,8 +19,15 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckIcon, CopyIcon, DownloadIcon, InfoIcon, LoaderCircleIcon } from "lucide-react" import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type CodeExportFormat = { export type CodeExportFormat = {
@@ -30,6 +37,8 @@ export type CodeExportFormat = {
code: string code: string
/** Empty / unavailable — show warning instead of code */ /** Empty / unavailable — show warning instead of code */
emptyMessage?: string emptyMessage?: string
/** Content came from live device fetch */
isLive?: boolean
} }
function downloadText(filename: string, content: string) { function downloadText(filename: string, content: string) {
@@ -47,14 +56,14 @@ function highlightLine(line: string): string {
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground" if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
if (trimmed.startsWith("[")) return "text-info" if (trimmed.startsWith("[")) return "text-info"
if (trimmed.startsWith("/")) return "text-info" if (trimmed.startsWith("/")) return "text-info"
if (/^\s+[a-z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-primary" if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground" return "text-foreground/90"
} }
function CodeBlock({ code }: { code: string }) { function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""] const lines = code.length ? code.split("\n") : [""]
return ( return (
<pre className="px-4 py-4 text-[12px] font-mono leading-relaxed whitespace-pre-wrap break-all select-all"> <pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
{lines.map((line, i) => ( {lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}> <span key={i} className={cn("block", highlightLine(line))}>
{line || " "} {line || " "}
@@ -75,6 +84,7 @@ export interface CodeExportSheetProps {
/** Optional live fetch from device */ /** Optional live fetch from device */
onLiveFetch?: (formatId: string) => void onLiveFetch?: (formatId: string) => void
liveBusy?: boolean liveBusy?: boolean
/** Short CTA — keep laconic */
liveLabel?: string liveLabel?: string
className?: string className?: string
/** Extra content under format tabs (e.g. meta strip) */ /** Extra content under format tabs (e.g. meta strip) */
@@ -84,6 +94,7 @@ export interface CodeExportSheetProps {
/** /**
* Shared code export Sheet — ReUI Frame + sticky footer. * Shared code export Sheet — ReUI Frame + sticky footer.
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame * Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
* Alert: https://reui.io/docs/components/base/alert
*/ */
function CodeExportSheet({ function CodeExportSheet({
open, open,
@@ -94,7 +105,7 @@ function CodeExportSheet({
initialFormatId, initialFormatId,
onLiveFetch, onLiveFetch,
liveBusy, liveBusy,
liveLabel = "Подтянуть с роутера (с private-key)", liveLabel = "С роутера",
className, className,
beforeCode, beforeCode,
}: CodeExportSheetProps) { }: CodeExportSheetProps) {
@@ -104,9 +115,11 @@ function CodeExportSheet({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setTab(initialFormatId && formats.some((f) => f.id === initialFormatId) setTab(
? initialFormatId initialFormatId && formats.some((f) => f.id === initialFormatId)
: firstId) ? initialFormatId
: firstId,
)
setCopied(false) setCopied(false)
}, [open, initialFormatId, firstId, formats]) }, [open, initialFormatId, firstId, formats])
@@ -119,124 +132,149 @@ function CodeExportSheet({
const emptyMessage = active?.emptyMessage const emptyMessage = active?.emptyMessage
const filename = active?.filename ?? "export.txt" const filename = active?.filename ?? "export.txt"
const canCopy = Boolean(code) && !emptyMessage const canCopy = Boolean(code) && !emptyMessage
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
function handleCopy() { function handleCopy() {
if (!canCopy) return if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => { void navigator.clipboard.writeText(code).then(() => {
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 1800)
}) })
} }
const showTabs = formats.length > 1
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent <SheetContent
className={cn( className={cn(
"flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl", "flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
className, className,
)} )}
> >
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12"> <SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle>{title}</SheetTitle> <SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? ( {description ? (
<SheetDescription>{description}</SheetDescription> <SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null} ) : null}
</SheetHeader> </SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-6 pt-3"> <div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
{showTabs ? ( {showTabs ? (
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(v) => setTab(String(v))} onValueChange={(v) => setTab(String(v))}
className="shrink-0" className="shrink-0 gap-0"
> >
<TabsList> <TabsList className="h-9 w-full">
{formats.map((f) => ( {formats.map((f) => (
<TabsTrigger key={f.id} value={f.id}> <TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label} {f.label}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{formats.map((f) => (
<TabsContent key={f.id} value={f.id} className="mt-0 hidden" />
))}
</Tabs> </Tabs>
) : null} ) : null}
{onLiveFetch ? ( {onLiveFetch || beforeCode ? (
<Alert variant="info" className="shrink-0"> <div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<InfoIcon /> <div className="flex min-w-0 flex-wrap items-center gap-2">
<AlertTitle>Live с роутера</AlertTitle> {onLiveFetch ? (
<AlertDescription> <Badge
Подтянуть актуальный конфиг с private-key с устройства. variant={isLive ? "success-light" : "secondary"}
</AlertDescription> size="sm"
<AlertAction> radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0"
disabled={liveBusy} disabled={liveBusy}
onClick={() => onLiveFetch(tab)} onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
> >
{liveBusy ? ( {liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" /> <LoaderCircleIcon className="size-3.5 animate-spin" />
) : null} ) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel} {liveBusy ? "Загрузка…" : liveLabel}
</Button> </Button>
</AlertAction> ) : null}
</Alert> </div>
) : null} ) : null}
{beforeCode} <Frame dense className="flex min-h-0 flex-1 flex-col">
<Frame dense className="min-h-0 flex-1 flex flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0"> <FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? ( {emptyMessage ? (
<div className="p-4"> <div className="flex flex-1 items-center p-4">
<Alert variant="warning"> <Alert variant="warning" className="w-full">
<InfoIcon /> <TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle> <AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription> <AlertDescription>{emptyMessage}</AlertDescription>
</Alert> </Alert>
</div> </div>
) : ( ) : (
<ScrollArea className="h-full min-h-[12rem] max-h-[min(60vh,28rem)]"> <ScrollArea className="h-full min-h-0 flex-1">
<CodeBlock code={code} /> <div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea> </ScrollArea>
)} )}
</FramePanel> </FramePanel>
</Frame> </Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div> </div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch"> <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 variant="outline" className="flex-1" />}> <SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть Закрыть
</SheetClose> </SheetClose>
<Button <div className="flex items-center gap-2">
type="button" <Button
variant="outline" type="button"
className="flex-1" variant="outline"
disabled={!canCopy} size="default"
onClick={() => downloadText(filename, code)} disabled={!canCopy}
> onClick={() => downloadText(filename, code)}
<DownloadIcon className="size-4" /> >
Файл <DownloadIcon className="size-4" />
</Button> Файл
<Button </Button>
type="button" <Button
className="flex-1" type="button"
disabled={!canCopy} size="default"
onClick={handleCopy} disabled={!canCopy}
> onClick={handleCopy}
{copied ? ( className="min-w-28"
<CheckIcon className="size-4 text-success" /> >
) : ( {copied ? (
<CopyIcon className="size-4" /> <CheckIcon className="size-4 text-success" />
)} ) : (
{copied ? "Скопировано" : "Копировать"} <CopyIcon className="size-4" />
</Button> )}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+236
View File
@@ -0,0 +1,236 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import type { ServerStatus, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
export const ALL_SERVERS_ID = "all"
export interface ServerTileItem {
id: string
name: string
country?: string
status?: ServerStatus
type?: ServerType
count?: number
enabled?: boolean
selectable?: boolean
title?: string
}
function typeBadgeVariant(
type: ServerType,
): "focus-light" | "info-light" | "success-light" {
if (type === "jump-host") return "focus-light"
if (type === "home-router") return "success-light"
return "info-light"
}
function typeBadgeLabel(type: ServerType): string {
if (type === "jump-host") return "JH"
if (type === "home-router") return "HR"
return "EN"
}
function ServerTypeBadge({ type }: { type: ServerType }) {
return (
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
{typeBadgeLabel(type)}
</Badge>
)
}
function matchesQuery(item: ServerTileItem, q: string): boolean {
if (!q) return true
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
return hay.includes(q)
}
function ServerTileRail({
items,
selectedId,
onSelect,
allCount = 0,
showHeader = true,
showAll = true,
showCount = true,
showType = true,
headerRight,
className,
}: {
items: ServerTileItem[]
selectedId: string
onSelect: (id: string) => void
allCount?: number
showHeader?: boolean
showAll?: boolean
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
className?: string
}) {
const [query, setQuery] = useState("")
const q = query.trim().toLowerCase()
const filtered = useMemo(
() => items.filter((item) => matchesQuery(item, q)),
[items, q],
)
const showAllTile = showAll && !q
return (
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
{showHeader ? (
<div className="flex items-center justify-between gap-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Серверы
</FrameTitle>
{headerRight}
</div>
) : headerRight ? (
<div className="flex justify-end">{headerRight}</div>
) : null}
<InputGroup>
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
aria-label="Поиск сервера"
/>
</InputGroup>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
{showAllTile ? (
<ServerTileButton
selected={selectedId === ALL_SERVERS_ID}
onSelect={() => onSelect(ALL_SERVERS_ID)}
title="Все серверы"
name="Все"
count={showCount ? allCount : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
<LayersIcon className="text-muted-foreground" />
</IconTile>
}
/>
) : null}
{filtered.map((item) => (
<ServerTileButton
key={item.id}
selected={selectedId === item.id}
onSelect={() => onSelect(item.id)}
title={item.title ?? item.name}
name={item.name}
count={showCount ? item.count : undefined}
enabled={item.enabled}
selectable={item.selectable}
status={item.status}
type={showType ? item.type : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
{item.country ? (
<Flag code={item.country} size={16} />
) : (
<ServerIcon className="text-muted-foreground" />
)}
</IconTile>
}
/>
))}
{filtered.length === 0 && !showAllTile ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
</p>
) : null}
</div>
</ScrollArea>
</FramePanel>
</Frame>
)
}
function ServerTileButton({
selected,
onSelect,
title,
name,
count,
enabled = true,
selectable = true,
status,
type,
icon,
}: {
selected: boolean
onSelect: () => void
title: string
name: string
count?: number
enabled?: boolean
selectable?: boolean
status?: ServerStatus
type?: ServerType
icon: ReactNode
}) {
return (
<button
type="button"
role="option"
aria-selected={selected}
title={title}
disabled={!selectable}
onClick={onSelect}
className={cn(
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
selected
? "bg-muted ring-1 ring-border"
: "hover:bg-muted/60",
!enabled && !selected && "opacity-40",
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
)}
>
{icon}
<span className="flex min-w-0 flex-1 items-center gap-1.5">
{status ? <StatusDot status={status} /> : null}
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
{type ? <ServerTypeBadge type={type} /> : null}
</span>
{count != null ? (
<Badge
variant={selected ? "secondary" : "outline"}
size="xs"
className="tabular-nums"
>
{count}
</Badge>
) : null}
</button>
)
}
export { ServerTileRail, ServerTypeBadge }
+9 -8
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -51,30 +51,31 @@ function WgCreateSheet({
onOpenChange, onOpenChange,
servers, servers,
busy, busy,
defaultServerId,
onSubmit, onSubmit,
}: { }: {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
servers: ServerOption[] servers: ServerOption[]
busy?: boolean busy?: boolean
defaultServerId?: string
onSubmit: (form: WgCreateFormState) => void | Promise<void> onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) { }) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm) const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) => const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v })) setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}, [open, defaultServerId])
const canSubmit = useMemo(() => { const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort) return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort]) }, [form.serverId, form.name, form.listenPort])
return ( return (
<Sheet <Sheet open={open} onOpenChange={onOpenChange}>
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> <SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle> <SheetTitle>Быстрый туннель WireGuard</SheetTitle>
+10 -6
View File
@@ -35,11 +35,11 @@ function WgExportSheet({
const formats = useMemo((): CodeExportFormat[] => { const formats = useMemo((): CodeExportFormat[] => {
if (!iface) { if (!iface) {
return [ return [
{ id: "rsc", label: "MikroTik .rsc", filename: "wg.rsc", code: "" }, { id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
{ id: "conf", label: "Native .conf", filename: "wg.conf", code: "" }, { id: "conf", label: ".conf", filename: "wg.conf", code: "" },
{ {
id: "peer", id: "peer",
label: "Peer .conf", label: "Peer",
filename: "wg-peer.conf", filename: "wg-peer.conf",
code: "", code: "",
emptyMessage: "Интерфейс не выбран", emptyMessage: "Интерфейс не выбран",
@@ -99,22 +99,25 @@ function WgExportSheet({
return [ return [
{ {
id: "rsc", id: "rsc",
label: "MikroTik .rsc", label: ".rsc",
filename: `${iface.name}.rsc`, filename: `${iface.name}.rsc`,
code: liveContent?.rsc ?? localRsc, code: liveContent?.rsc ?? localRsc,
isLive: Boolean(liveContent?.rsc),
}, },
{ {
id: "conf", id: "conf",
label: "Native .conf", label: ".conf",
filename: `${iface.name}.conf`, filename: `${iface.name}.conf`,
code: liveContent?.conf ?? localConf, code: liveContent?.conf ?? localConf,
isLive: Boolean(liveContent?.conf),
}, },
{ {
id: "peer", id: "peer",
label: "Peer .conf", label: "Peer",
filename: `${iface.name}-peer.conf`, filename: `${iface.name}-peer.conf`,
code: liveContent?.peerConf ?? peerConf, code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty, emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
isLive: Boolean(liveContent?.peerConf),
}, },
] ]
}, [iface, liveContent, peerId]) }, [iface, liveContent, peerId])
@@ -128,6 +131,7 @@ function WgExportSheet({
formats={formats} formats={formats}
initialFormatId={initialTab} initialFormatId={initialTab}
liveBusy={liveBusy} liveBusy={liveBusy}
liveLabel="С роутера"
onLiveFetch={ onLiveFetch={
onRequestLiveExport onRequestLiveExport
? (formatId) => ? (formatId) =>
+11 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit" import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -17,12 +17,14 @@ function WgImportSheet({
onOpenChange, onOpenChange,
servers, servers,
busy, busy,
defaultServerId,
onImport, onImport,
}: { }: {
open: boolean open: boolean
onOpenChange: (v: boolean) => void onOpenChange: (v: boolean) => void
servers: ServerOption[] servers: ServerOption[]
busy?: boolean busy?: boolean
defaultServerId?: string
onImport: (args: { onImport: (args: {
serverId: string serverId: string
content: string content: string
@@ -41,6 +43,11 @@ function WgImportSheet({
[content], [content],
) )
useEffect(() => {
if (!open) return
setServerId(defaultServerId ?? "")
}, [open, defaultServerId])
function runPreview() { function runPreview() {
setParseError(null) setParseError(null)
setPreview(null) setPreview(null)
@@ -66,7 +73,9 @@ function WgImportSheet({
<Sheet <Sheet
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
if (!v) { if (v) {
setServerId(defaultServerId ?? "")
} else {
setContent("") setContent("")
setPreview(null) setPreview(null)
setParseError(null) setParseError(null)
+15
View File
@@ -13875,6 +13875,21 @@
"dependencies": { "dependencies": {
"zod": "^4.4.1" "zod": "^4.4.1"
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+1 -1
View File
File diff suppressed because one or more lines are too long