fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены. Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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({
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
@@ -152,7 +153,7 @@ function WireguardDataGrid({
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
return (
|
||||
<span className="font-mono text-sm text-center block">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-success">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -168,16 +169,13 @@ function WireguardDataGrid({
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
<Badge
|
||||
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
|
||||
size="sm"
|
||||
className="font-mono"
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
|
||||
@@ -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 (
|
||||
<pre className="px-4 py-4 text-[12px] font-mono leading-relaxed whitespace-pre-wrap break-all select-all">
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} className={cn("block", highlightLine(line))}>
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? (
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-6 pt-3">
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(String(v))}
|
||||
className="shrink-0"
|
||||
>
|
||||
<TabsList>
|
||||
{formats.map((f) => (
|
||||
<TabsTrigger key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{formats.map((f) => (
|
||||
<TabsContent key={f.id} value={f.id} className="mt-0 hidden" />
|
||||
))}
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{onLiveFetch ? (
|
||||
<Alert variant="info" className="shrink-0">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Live с роутера</AlertTitle>
|
||||
<AlertDescription>
|
||||
Подтянуть актуальный конфиг с private-key с устройства.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={liveBusy}
|
||||
onClick={() => onLiveFetch(tab)}
|
||||
>
|
||||
{liveBusy ? (
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
{liveBusy ? "Загрузка…" : liveLabel}
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{beforeCode}
|
||||
|
||||
<Frame dense className="min-h-0 flex-1 flex flex-col">
|
||||
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
{emptyMessage ? (
|
||||
<div className="p-4">
|
||||
<Alert variant="warning">
|
||||
<InfoIcon />
|
||||
<AlertTitle>Нет данных</AlertTitle>
|
||||
<AlertDescription>{emptyMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full min-h-[12rem] max-h-[min(60vh,28rem)]">
|
||||
<CodeBlock code={code} />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!canCopy}
|
||||
onClick={() => downloadText(filename, code)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Файл
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!canCopy}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeExportSheet, downloadText }
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<KpiStatVariant, string> = {
|
||||
default: "text-foreground",
|
||||
warning: "text-warning",
|
||||
destructive: "text-destructive",
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
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 (
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? "default"
|
||||
|
||||
return (
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link href={item.href} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn("grid gap-2", kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link href={item.href} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <KpiStatGridSkeleton count={skeletonCount} />
|
||||
}
|
||||
|
||||
const list = items ?? cards ?? []
|
||||
|
||||
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn("w-full", className)}>
|
||||
<FramePanel className="flex items-center gap-3 p-4">
|
||||
{emptyIcon}
|
||||
{emptyMessage ? (
|
||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт WireGuard</SheetTitle>
|
||||
<SheetDescription>
|
||||
{iface ? `${iface.name} · ${iface.serverName}` : "—"}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadText(filename, code)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
Файл
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6 pt-3 shrink-0">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="rsc">MikroTik .rsc</TabsTrigger>
|
||||
<TabsTrigger value="conf">Native .conf</TabsTrigger>
|
||||
<TabsTrigger value="peer">Peer .conf</TabsTrigger>
|
||||
</TabsList>
|
||||
{onRequestLiveExport && (
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={liveBusy}
|
||||
onClick={() =>
|
||||
onRequestLiveExport(
|
||||
tab === "peer" ? "peer-conf" : tab === "conf" ? "conf" : "rsc",
|
||||
)
|
||||
}
|
||||
>
|
||||
{liveBusy ? "Загрузка с роутера…" : "Подтянуть с роутера (с private-key)"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<TabsContent value="rsc" className="mt-0" />
|
||||
<TabsContent value="conf" className="mt-0" />
|
||||
<TabsContent value="peer" className="mt-0" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{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 (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
isComment
|
||||
? "text-muted-foreground"
|
||||
: isCmd || isSection
|
||||
? "text-sky-400"
|
||||
: isParam
|
||||
? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}
|
||||
>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт WireGuard"
|
||||
description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
|
||||
formats={formats}
|
||||
initialFormatId={initialTab}
|
||||
liveBusy={liveBusy}
|
||||
onLiveFetch={
|
||||
onRequestLiveExport
|
||||
? (formatId) =>
|
||||
onRequestLiveExport(
|
||||
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user