diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index 2c6339a..533d57b 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -17,13 +17,14 @@ import { import { BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, - CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon, + CodeXmlIcon, ActivityIcon, ServerIcon, TerminalIcon, AlertCircleIcon, } from "lucide-react" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" +import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" // ─── helpers ────────────────────────────────────────────────────────────────── @@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string { function ExportSheet({ open, container, onClose }: { open: boolean; container: RouterContainer | null; onClose: () => void }) { - const [copied, setCopied] = useState(false) const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) - function handleCopy() { - navigator.clipboard.writeText(code).then(() => { - setCopied(true); setTimeout(() => setCopied(false), 2000) - }) - } - return ( - { if (!v) onClose() }}> - - -
-
- Экспорт Container - RouterOS 7.4+ · /container · /interface/veth -
- -
-
-
-
-            {code.split("\n").map((line, i) => {
-              const isComment = line.startsWith("#")
-              const isCmd = /^\//.test(line.trimStart())
-              const isParam = /^\s+[a-z]/.test(line)
-              return (
-                
-                  {line}{"\n"}
-                
-              )
-            })}
-          
-
- - }>Закрыть - - -
-
+ ) } diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index e5434e9..2b26f95 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -34,12 +34,13 @@ import { cn } from "@/lib/utils" import { PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, - MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon, - CheckIcon, PowerIcon, CheckCircleIcon, + MoreHorizontalIcon, PencilIcon, Trash2Icon, CodeXmlIcon, + PowerIcon, CheckCircleIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, SlidersHorizontalIcon, } from "lucide-react" +import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -602,68 +603,28 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: { function ExportSheet({ open, onClose, rules }: { open: boolean; onClose: () => void; rules: FirewallRule[] }) { - const [copied, setCopied] = useState(false) - - // Derived from open — new timestamp each time modal opens, undefined when closed const timestamp = useMemo( () => open ? new Date().toLocaleString("ru") : undefined, - [open], + [open], ) const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) - function handleCopy() { - const full = generateRsc(rules, new Date().toLocaleString("ru")) - navigator.clipboard.writeText(full).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - } - return ( - { if (!v) onClose() }}> - - -
-
- Экспорт Firewall - RouterOS .rsc · /ip firewall filter, nat, mangle -
- -
-
-
-
-            {code.split("\n").map((line, i) => {
-              const isComment = line.startsWith("#")
-              const isSection = isComment && line.includes("──")
-              const isKey     = /^\s+[a-z]/.test(line)
-              return (
-                
-                  {line}{"\n"}
-                
-              )
-            })}
-          
-
- - }>Закрыть - - -
-
+ ) } diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 75e7e59..f7bc9a3 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -31,9 +31,10 @@ import { PlusIcon, RefreshCwIcon, MoreHorizontalIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, - CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon, + CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, DatabaseIcon, } from "lucide-react" +import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" // ─── label maps ───────────────────────────────────────────────────────────── @@ -244,7 +245,6 @@ export default function GrePage() { const [tunnelOpen, setTunnelOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) - const [copied, setCopied] = useState(false) const [tForm, setTForm] = useState(defaultTunnelForm) const [pForm, setPForm] = useState(defaultPoolForm) @@ -366,13 +366,10 @@ export default function GrePage() { { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, ] - function handleCopy(code: string) { - navigator.clipboard.writeText(code).then(() => { - setCopied(true) - toast.success("Команды скопированы") - setTimeout(() => setCopied(false), 2000) - }) - } + const greExportCode = useMemo( + () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""), + [codePreviewTunnel, serverById], + ) return (
@@ -551,82 +548,47 @@ export default function GrePage() {
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} - { if (!open) setCodePreviewTunnel(null) }}> - - {codePreviewTunnel && (() => { - const code = generateRosCommands(codePreviewTunnel, serverById) - return ( - <> - -
-
- {codePreviewTunnel.name} - Команды RouterOS 7.20+ для создания туннеля -
- -
-
- -
- {/* meta strip */} -
- - - {STATUS_MAP[codePreviewTunnel.status].label} - - · - {serverById[codePreviewTunnel.serverId]?.name} - · - {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress} - {codePreviewTunnel.ipsec && ( - <> - · - IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} - - )} -
- - {/* code block */} -
-                    {code.split("\n").map((line, i) => {
-                      const isComment  = line.startsWith("#")
-                      const isSection  = isComment && line.includes("──")
-                      const isKey      = /^\s+[a-z]/.test(line)
-                      return (
-                        
-                          {line}
-                          {"\n"}
-                        
-                      )
-                    })}
-                  
-
- - - }>Закрыть - - - - ) - })()} -
-
+ setCodePreviewTunnel(null)} + title={codePreviewTunnel?.name ?? "GRE"} + description="Команды RouterOS 7.20+ для создания туннеля" + formats={[ + { + id: "rsc", + label: "RouterOS", + filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`, + code: greExportCode, + }, + ]} + beforeCode={ + codePreviewTunnel ? ( +
+ + + {STATUS_MAP[codePreviewTunnel.status].label} + + · + {serverById[codePreviewTunnel.serverId]?.name} + · + + {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} + {" → "} + {codePreviewTunnel.remoteAddress} + + {codePreviewTunnel.ipsec ? ( + <> + · + + + IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} + + + ) : null} +
+ ) : null + } + /> {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index 47e7f50..4842448 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -13,13 +13,9 @@ import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { - NetworkIcon, PlusIcon, CopyIcon, CheckIcon, - CodeXmlIcon, LayersIcon, + NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon, } from "lucide-react" -import { - Sheet, SheetContent, SheetHeader, SheetTitle, - SheetDescription, SheetFooter, SheetClose, -} from "@/components/ui/sheet" +import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" // ─── helpers ────────────────────────────────────────────────────────────────── @@ -70,57 +66,23 @@ function generateVxlanRsc(t: VxlanTunnel): string { function ExportSheet({ open, tunnel, onClose }: { open: boolean; tunnel: VxlanTunnel | null; onClose: () => void }) { - const [copied, setCopied] = useState(false) const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) - function handleCopy() { - navigator.clipboard.writeText(code).then(() => { - setCopied(true); setTimeout(() => setCopied(false), 2000) - }) - } - return ( - { if (!v) onClose() }}> - - -
-
- Экспорт VXLAN - RouterOS 7.x · /interface/vxlan + vteps -
- -
-
-
-
-            {code.split("\n").map((line, i) => {
-              const isComment = line.startsWith("#")
-              const isCmd = /^\//.test(line.trimStart())
-              const isParam = /^\s+[a-z]/.test(line)
-              return (
-                
-                  {line}{"\n"}
-                
-              )
-            })}
-          
-
- - }>Закрыть - - -
-
+ ) } diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 2481e7a..088e4e7 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -11,8 +11,12 @@ import { type WgIfaceWithServer, } from "@/components/data-grids/wireguard-data-grid" import { Button } from "@/components/ui/button" -import { Frame, FramePanel } from "@/components/reui/frame" -import { IconTile } from "@/components/reui/icon-tile" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/reui/alert" +import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" @@ -34,7 +38,7 @@ import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-pee import { toast } from "sonner" import { ShieldCheckIcon, PlusIcon, KeyRoundIcon, - UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, + UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon, } from "lucide-react" function collectMockInterfaces(): WgIfaceWithServer[] { @@ -139,6 +143,8 @@ export default function WireGuardPage() { const [createOpen, setCreateOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) const [exportIface, setExportIface] = useState(null) + const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc") + const [exportPeerId, setExportPeerId] = useState(null) const [peerIface, setPeerIface] = useState(null) const [liveExport, setLiveExport] = useState<{ rsc?: string @@ -372,6 +378,7 @@ export default function WireGuardPage() { interfaceName: exportIface.name, format, includePrivateKey: format !== "peer-conf", + peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined, }) setLiveExport((prev) => ({ ...prev, @@ -389,6 +396,13 @@ export default function WireGuardPage() { } } + function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) { + setLiveExport(null) + setExportInitialTab(tab) + setExportPeerId(peerId ?? null) + setExportIface(iface) + } + return (
-
- {[ - { label: "Интерфейсов", value: displayIfaces.length, icon: }, - { label: "Активных (UP)", value: upIfaces, icon: }, - { label: "Всего пиров", value: totalPeers, icon: }, - { label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: }, - ].map((s) => ( - - - -
-

{s.label}

-

{s.value}

-
-
- - ))} -
+ , + iconClassName: "text-muted-foreground", + }, + { + id: "up", + label: "Активных (UP)", + value: upIfaces, + icon: , + iconClassName: "text-success", + }, + { + id: "peers", + label: "Всего пиров", + value: totalPeers, + icon: , + iconClassName: "text-info", + }, + { + id: "online", + label: "Пиров онлайн", + value: `${onlinePeers}/${totalPeers}`, + icon: , + iconClassName: "text-primary", + }, + ]} + /> -
- -
-

- WireGuard — live-интеграция RouterOS 7.x -

-

- {isLive - ? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера." - : "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."} -

-
-
+ + + WireGuard — live-интеграция RouterOS 7.x + + {isLive + ? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера." + : "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."} + + { - setLiveExport(null) - setExportIface(iface) - }} + onExport={(iface) => openExport(iface, "rsc")} onAddPeer={setPeerIface} onToggle={handleToggle} onDelete={handleDelete} onDeletePeer={handleDeletePeer} - onExportPeer={(iface) => { - setLiveExport(null) - setExportIface(iface) - }} + onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} /> @@ -518,7 +535,7 @@ export default function WireGuardPage() {

{b.title}

-
+                  
                     {b.lines.join("\n")}
                   
@@ -552,8 +569,12 @@ export default function WireGuardPage() { { setExportIface(null) + setExportPeerId(null) + setExportInitialTab("rsc") setLiveExport(null) }} liveContent={liveExport} diff --git a/components/data-grids/wireguard-data-grid.tsx b/components/data-grids/wireguard-data-grid.tsx index 98a01aa..805e113 100644 --- a/components/data-grids/wireguard-data-grid.tsx +++ b/components/data-grids/wireguard-data-grid.tsx @@ -11,6 +11,7 @@ import { import type { WireGuardInterface } from "@/lib/data" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" +import { Badge } from "@/components/reui/badge" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -88,7 +89,7 @@ function WireguardDataGrid({ {iface.name} @@ -152,7 +153,7 @@ function WireguardDataGrid({ const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length return ( - {onlinePeers} + {onlinePeers} /{iface.peers.length} ) @@ -168,16 +169,13 @@ function WireguardDataGrid({ accessorKey: "status", header: ({ column }) => , cell: ({ row }) => ( - {row.original.status === "up" ? "UP" : "DOWN"} - + ), meta: { headerTitle: "Статус", diff --git a/components/reui-kit/code-export-sheet.tsx b/components/reui-kit/code-export-sheet.tsx new file mode 100644 index 0000000..9c1f495 --- /dev/null +++ b/components/reui-kit/code-export-sheet.tsx @@ -0,0 +1,246 @@ +"use client" + +import { useEffect, useMemo, useState, type ReactNode } from "react" +import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, +} from "@/components/reui/alert" +import { Frame, FramePanel } from "@/components/reui/frame" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { CheckIcon, CopyIcon, DownloadIcon, InfoIcon, LoaderCircleIcon } from "lucide-react" +import { cn } from "@/lib/utils" + +export type CodeExportFormat = { + id: string + label: string + filename: string + code: string + /** Empty / unavailable — show warning instead of code */ + emptyMessage?: string +} + +function downloadText(filename: string, content: string) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function highlightLine(line: string): string { + const trimmed = line.trimStart() + if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground" + 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" + return "text-foreground" +} + +function CodeBlock({ code }: { code: string }) { + const lines = code.length ? code.split("\n") : [""] + return ( +
+      {lines.map((line, i) => (
+        
+          {line || " "}
+        
+      ))}
+    
+ ) +} + +export interface CodeExportSheetProps { + open: boolean + onClose: () => void + title: string + description?: ReactNode + formats: CodeExportFormat[] + /** Initial format id when sheet opens */ + initialFormatId?: string + /** Optional live fetch from device */ + onLiveFetch?: (formatId: string) => void + liveBusy?: boolean + liveLabel?: string + className?: string + /** Extra content under format tabs (e.g. meta strip) */ + beforeCode?: ReactNode +} + +/** + * 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 + */ +function CodeExportSheet({ + open, + onClose, + title, + description, + formats, + initialFormatId, + onLiveFetch, + liveBusy, + liveLabel = "Подтянуть с роутера (с private-key)", + className, + beforeCode, +}: CodeExportSheetProps) { + const firstId = formats[0]?.id ?? "default" + const [tab, setTab] = useState(initialFormatId ?? firstId) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!open) return + setTab(initialFormatId && formats.some((f) => f.id === initialFormatId) + ? initialFormatId + : firstId) + setCopied(false) + }, [open, initialFormatId, firstId, formats]) + + const active = useMemo( + () => formats.find((f) => f.id === tab) ?? formats[0], + [formats, tab], + ) + + const code = active?.code ?? "" + const emptyMessage = active?.emptyMessage + const filename = active?.filename ?? "export.txt" + const canCopy = Boolean(code) && !emptyMessage + + function handleCopy() { + if (!canCopy) return + void navigator.clipboard.writeText(code).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + const showTabs = formats.length > 1 + + return ( + { if (!v) onClose() }}> + + + {title} + {description ? ( + {description} + ) : null} + + +
+ {showTabs ? ( + setTab(String(v))} + className="shrink-0" + > + + {formats.map((f) => ( + + {f.label} + + ))} + + {formats.map((f) => ( + + ))} + + ) : null} + + {onLiveFetch ? ( + + + Live с роутера + + Подтянуть актуальный конфиг с private-key с устройства. + + + + + + ) : null} + + {beforeCode} + + + + {emptyMessage ? ( +
+ + + Нет данных + {emptyMessage} + +
+ ) : ( + + + + )} +
+ +
+ + + }> + Закрыть + + + + +
+
+ ) +} + +export { CodeExportSheet, downloadText } diff --git a/components/reui-kit/index.ts b/components/reui-kit/index.ts new file mode 100644 index 0000000..36aff5e --- /dev/null +++ b/components/reui-kit/index.ts @@ -0,0 +1,5 @@ +export { CodeExportSheet, downloadText } from "./code-export-sheet" +export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet" +export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid" +export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid" +export { kpiCols } from "./kpi-cols" diff --git a/components/reui-kit/kpi-cols.ts b/components/reui-kit/kpi-cols.ts new file mode 100644 index 0000000..8230dcf --- /dev/null +++ b/components/reui-kit/kpi-cols.ts @@ -0,0 +1,12 @@ +/** + * Shared grid column classes for hybrid KPI tiles. + * @see https://reui.io/preview/base/stats-12 + */ +export function kpiCols(count: number): string { + if (count <= 1) return "grid-cols-1" + if (count === 2) return "grid-cols-1 @xl:grid-cols-2" + if (count === 3) return "grid-cols-1 @3xl:grid-cols-3" + if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4" + if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3" + return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4" +} diff --git a/components/reui-kit/kpi-stat-grid.tsx b/components/reui-kit/kpi-stat-grid.tsx new file mode 100644 index 0000000..5e63eb2 --- /dev/null +++ b/components/reui-kit/kpi-stat-grid.tsx @@ -0,0 +1,307 @@ +"use client" + +import type { KeyboardEvent, ReactNode } from "react" +import Link from "next/link" +import { Frame, FramePanel } from "@/components/reui/frame" +import { Badge } from "@/components/reui/badge" +import { IconTile } from "@/components/reui/icon-tile" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" +import { kpiCols } from "./kpi-cols" + +export type KpiStatVariant = "default" | "warning" | "destructive" + +/** + * KPI tile — horizontal compact hybrid (icon left + label/Badge + value). + * @see https://reui.io/preview/base/stats-12 + */ +export type KpiStatItem = { + id?: string + label: ReactNode + value: ReactNode + hint?: ReactNode + href?: string + onSelect?: () => void + onClick?: () => void + selected?: boolean + active?: boolean + icon?: ReactNode + iconClassName?: string + variant?: KpiStatVariant + footer?: ReactNode +} + +export type KpiStatCardData = KpiStatItem & { id: string } + +const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current" + +const VALUE_VARIANT_CLASS: Record = { + default: "text-foreground", + warning: "text-warning", + destructive: "text-destructive", +} + +function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onActivate() + } +} + +function resolveActivate(item: KpiStatItem): (() => void) | undefined { + return item.onClick ?? item.onSelect +} + +function isSelected(item: KpiStatItem): boolean { + return Boolean(item.selected ?? item.active) +} + +function resolveFooter(item: KpiStatItem): ReactNode { + if (item.footer) return item.footer + if (typeof item.hint === "string") { + return ( + + {item.hint} + + ) + } + if (item.hint) return item.hint + return null +} + +function KpiStatCardBody({ item }: { item: KpiStatItem }) { + const footer = resolveFooter(item) + const valueVariant = item.variant ?? "default" + + return ( +
+ {item.icon ? ( + + ) : null} + +
+
+
+ {item.label} +
+ {footer ? ( +
+ {footer} +
+ ) : null} +
+
+ {item.value} +
+ {footer ? ( +
{footer}
+ ) : null} +
+
+ ) +} + +function panelClassName(item: KpiStatItem, className?: string) { + const onActivate = resolveActivate(item) + const clickable = Boolean(item.href || onActivate) + const selected = isSelected(item) + + return cn( + "relative isolate flex h-full min-w-0 flex-col", + clickable && + "hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2", + selected && "ring-primary/30 bg-muted/30 ring-1", + className, + ) +} + +export function KpiStatCardTile({ + item, + embedded = false, + className, +}: { + item: KpiStatItem + embedded?: boolean + className?: string +}) { + const onActivate = resolveActivate(item) + const panelClass = panelClassName(item, className) + + let panel: ReactNode + + if (item.href) { + panel = ( + + + + + + ) + } else if (onActivate) { + panel = ( + handleCardKeyDown(onActivate, e)} + > + + + ) + } else { + panel = ( + + + + ) + } + + if (embedded) { + return {panel} + } + + return {panel} +} + +function KpiStatGridSkeleton({ count }: { count: number }) { + return ( + +
+ {Array.from({ length: count }).map((_, index) => ( + + +
+
+ + +
+ +
+
+ ))} +
+ + ) +} + +interface KpiStatGridProps { + items?: KpiStatItem[] + cards?: KpiStatCardData[] + isLoading?: boolean + emptyMessage?: ReactNode + emptyIcon?: ReactNode + className?: string + skeletonCount?: number + embedded?: boolean + "aria-label"?: string +} + +function KpiStatCardItem({ item }: { item: KpiStatItem }) { + const onActivate = resolveActivate(item) + const panelClass = panelClassName(item) + + if (item.href) { + return ( + + + + + + ) + } + + if (onActivate) { + return ( + handleCardKeyDown(onActivate, e)} + > + + + ) + } + + return ( + + + + ) +} + +/** + * Hybrid KPI — horizontal compact layout (icon left). + * Preview: https://reui.io/preview/base/stats-12 + */ +export function KpiStatGrid({ + items, + cards, + isLoading = false, + emptyMessage, + emptyIcon, + className, + skeletonCount = 4, + embedded = false, + "aria-label": ariaLabel, +}: KpiStatGridProps) { + if (isLoading) { + return + } + + const list = items ?? cards ?? [] + + if (list.length === 0 && (emptyMessage || emptyIcon)) { + return ( + + + {emptyIcon} + {emptyMessage ? ( +

{emptyMessage}

+ ) : null} +
+ + ) + } + + if (embedded) { + return ( +
+
+ {list.map((item, index) => ( + + ))} +
+
+ ) + } + + return ( + +
+ {list.map((item, index) => ( + + ))} +
+ + ) +} + +export function kpiStatItemKey(item: KpiStatItem, index: number): string { + if (item.id) return item.id + if (typeof item.label === "string") return item.label + return `kpi-${index}` +} diff --git a/components/wireguard/wg-export-sheet.tsx b/components/wireguard/wg-export-sheet.tsx index 4e65440..96675ef 100644 --- a/components/wireguard/wg-export-sheet.tsx +++ b/components/wireguard/wg-export-sheet.tsx @@ -1,29 +1,16 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useMemo } from "react" import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid" -import { Button } from "@/components/ui/button" import { - Sheet, SheetContent, SheetHeader, SheetTitle, - SheetDescription, SheetFooter, SheetClose, -} from "@/components/ui/sheet" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" + CodeExportSheet, + type CodeExportFormat, +} from "@/components/reui-kit/code-export-sheet" import { generateMikrotikRsc, generateNativeConf, generatePeerClientConf, } from "@/lib/wg-config" -import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react" - -function downloadText(filename: string, content: string) { - const blob = new Blob([content], { type: "text/plain;charset=utf-8" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) -} function WgExportSheet({ open, @@ -32,27 +19,34 @@ function WgExportSheet({ liveContent, liveBusy, onRequestLiveExport, + initialTab = "rsc", + peerId, }: { open: boolean iface: WgIfaceWithServer | null onClose: () => void - /** Optional server-fetched content (with private key) keyed by format */ liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null liveBusy?: boolean onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void + initialTab?: "rsc" | "conf" | "peer" + /** Selected peer for Peer .conf (defaults to first peer) */ + peerId?: string | null }) { - const [tab, setTab] = useState<"rsc" | "conf" | "peer">("rsc") - const [copied, setCopied] = useState(false) - - useEffect(() => { - if (open) { - setTab("rsc") - setCopied(false) + const formats = useMemo((): CodeExportFormat[] => { + if (!iface) { + return [ + { id: "rsc", label: "MikroTik .rsc", filename: "wg.rsc", code: "" }, + { id: "conf", label: "Native .conf", filename: "wg.conf", code: "" }, + { + id: "peer", + label: "Peer .conf", + filename: "wg-peer.conf", + code: "", + emptyMessage: "Интерфейс не выбран", + }, + ] } - }, [open, iface?.id]) - const local = useMemo(() => { - if (!iface) return { rsc: "", conf: "", peerConf: "" } const base = { name: iface.name, listenPort: iface.listenPort, @@ -76,143 +70,73 @@ function WgExportSheet({ clientEndpoint: p.clientEndpoint, })), } - const peer = iface.peers[0] - return { - rsc: generateMikrotikRsc(base), - conf: generateNativeConf(base), - peerConf: - iface.publicKey && peer - ? generatePeerClientConf({ - peerAddress: peer.clientAddress, - peerDns: peer.clientDns, - serverPublicKey: iface.publicKey, - allowedIps: peer.allowedIps, - endpoint: - peer.clientEndpoint || - peer.endpoint || - undefined, - persistentKeepalive: peer.persistentKeepalive ?? 25, - }) - : "# Нет public-key интерфейса или пиров для клиентского .conf\n", + + const peer = + (peerId + ? iface.peers.find((p) => p.id === peerId || p.rosId === peerId) + : undefined) ?? iface.peers[0] + + const localRsc = generateMikrotikRsc(base) + const localConf = generateNativeConf(base) + + let peerConf = "" + let peerEmpty: string | undefined + if (!iface.publicKey) { + peerEmpty = "Нет public-key интерфейса для клиентского .conf" + } else if (!peer) { + peerEmpty = "Нет пиров для экспорта Peer .conf" + } else { + peerConf = generatePeerClientConf({ + peerAddress: peer.clientAddress, + peerDns: peer.clientDns, + serverPublicKey: iface.publicKey, + allowedIps: peer.allowedIps, + endpoint: peer.clientEndpoint || peer.endpoint || undefined, + persistentKeepalive: peer.persistentKeepalive ?? 25, + }) } - }, [iface]) - const code = - tab === "rsc" - ? (liveContent?.rsc ?? local.rsc) - : tab === "conf" - ? (liveContent?.conf ?? local.conf) - : (liveContent?.peerConf ?? local.peerConf) - - const filename = - tab === "rsc" - ? `${iface?.name ?? "wg"}.rsc` - : tab === "conf" - ? `${iface?.name ?? "wg"}.conf` - : `${iface?.name ?? "wg"}-peer.conf` - - function handleCopy() { - void navigator.clipboard.writeText(code).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }) - } + return [ + { + id: "rsc", + label: "MikroTik .rsc", + filename: `${iface.name}.rsc`, + code: liveContent?.rsc ?? localRsc, + }, + { + id: "conf", + label: "Native .conf", + filename: `${iface.name}.conf`, + code: liveContent?.conf ?? localConf, + }, + { + id: "peer", + label: "Peer .conf", + filename: `${iface.name}-peer.conf`, + code: liveContent?.peerConf ?? peerConf, + emptyMessage: liveContent?.peerConf ? undefined : peerEmpty, + }, + ] + }, [iface, liveContent, peerId]) return ( - { if (!v) onClose() }}> - - -
-
- Экспорт WireGuard - - {iface ? `${iface.name} · ${iface.serverName}` : "—"} - -
-
- - -
-
-
- -
- setTab(v as typeof tab)}> - - MikroTik .rsc - Native .conf - Peer .conf - - {onRequestLiveExport && ( -
- -
- )} - - - -
-
- -
-
-            {code.split("\n").map((line, i) => {
-              const isComment = line.startsWith("#")
-              const isCmd = line.trimStart().startsWith("/interface") || line.trimStart().startsWith("/ip")
-              const isSection = line.startsWith("[")
-              const isParam = /^\s+[a-z]/.test(line) || /^[A-Za-z]+=/.test(line)
-              return (
-                
-                  {line}{"\n"}
-                
+    
+              onRequestLiveExport(
+                formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
               )
-            })}
-          
-
- - - }>Закрыть - - -
-
+ : undefined + } + /> ) }