Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6123660346 |
+147
-98
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user