Compare commits

..
4 Commits
Author SHA1 Message Date
Denozordec 6123660346 feat(terminal, wireguard): enhance UI components and functionality
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m36s
Docker images / frontend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 10s
Updated the TerminalPage to include a new ServerTileRail for server selection and added QuickCmds for quick command execution. Enhanced the WireGuardPage with new tabs for interface and peer management, improved server selection handling, and added support for displaying peer details. Refactored data grids to support compact server views and improved empty state handling. Introduced new hooks for managing server states and improved user experience across components.
2026-09-06 01:23:40 +07:00
Denozordec b680f882cc feat(ui): enhance CodeExportSheet and WgExportSheet components
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00
DenozordecandCursor 5e0c16e808 fix(ui): выровнять confirms и токены под ReUI PRO
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Добавить ui-design-contract, AlertDialog вместо hand-roll, CodeExportSheet для preview фильтров, ReUI Badge и gap вместо space-y.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:57 +07:00
DenozordecandCursor 66509b26bd fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены.
Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE.

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:50 +07:00
26 changed files with 2173 additions and 856 deletions
+16 -49
View File
@@ -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 (
<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>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<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 = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</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 ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Container"
description="RouterOS 7.4+ · /container · /interface/veth"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${container?.name ?? "container"}.rsc`,
code,
},
]}
/>
)
}
+28 -28
View File
@@ -4,7 +4,7 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null}
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null}
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null}
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null}
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы
</p>
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null}
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
<div className="space-y-3">
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{a.errors?.length ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs">
<AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert>
) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return (
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd>
</div>
<div className="sm:col-span-2 space-y-1">
<div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd>
<span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd>
</div>
<div className="space-y-1">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd>
<Badge
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div>
</dl>
{r.error ? (
<div className="space-y-1.5">
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<Separator />
<div className="space-y-2">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? (
<SnapshotTables snap={snapshot} />
@@ -1090,7 +1090,7 @@ export default function DataCollectionPage() {
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator />
<div className="space-y-3 px-5 py-4">
<div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
@@ -1142,7 +1142,7 @@ export default function DataCollectionPage() {
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика
</Button>
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p>
Трафик последний сбор:{" "}
{trafficCollector?.lastCollectedAt
+20 -89
View File
@@ -35,6 +35,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ── helpers ────────────────────────────────────────────────────────────────────
@@ -754,13 +755,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId),
[rulesets, serverId],
)
const server = serversList.find(s => s.id === serverId)
const server = serversList.find(s => s.id === serverId)
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo(
@@ -771,93 +770,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
)
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
{/* header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
<div className="flex items-center gap-2.5">
<FileCodeIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-semibold">RouterOS config</span>
{server && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
<Flag code={server.country} size={11} />
<span className="font-mono">{server.name}</span>
<span>· {totalRules} правил</span>
</span>
)}
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
</div>
{/* code */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
{config.split("\n").map((line, i) => {
const trimmed = line.trimStart()
const cls =
// section dividers
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
? "text-[#444c56]" :
// inline comments inside rule body
trimmed.startsWith("# ")
? "text-[#8b949e]" :
// RouterOS scripting keywords
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
? "text-[#d2a8ff]" :
// :if identity branch / closing brace
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
? "text-[#ff7b72] font-semibold" :
// filter rule add command
trimmed.startsWith("/routing filter rule")
? "text-[#79c0ff]" :
// rule body: if/else if branches
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
? "text-[#ff7b72]" :
// rule body: blackhole action
trimmed.startsWith("set type blackhole")
? "text-[#ff7b72] font-semibold" :
// rule body: set actions
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
? "text-[#a5d6ff]" :
// rule body: accept / rule close
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
? "text-[#79c0ff]" :
// named params
trimmed.match(/^(chain|comment|bgp-communities)=/)
? "text-[#a5d6ff]" :
"text-[#c9d1d9]"
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
})}
</pre>
</div>
{/* footer */}
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
<p>1. Скопируйте скрипт в буфер обмена</p>
<p>3. Вставьте в терминал и нажмите Enter</p>
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
<p>4. Скрипт сам определит свои правила по identity</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
<Button onClick={handleCopy} className="flex-1">
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано!" : "Скопировать"}
</Button>
</div>
</div>
</div>
</div>
<CodeExportSheet
open={open}
onClose={onClose}
title="RouterOS config"
description={
server
? `${server.name} · ${totalRules} правил`
: "Экспорт правил фильтрации"
}
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `filters-${server?.name ?? "server"}.rsc`,
code: config,
},
]}
/>
)
}
+18 -57
View File
@@ -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 (
<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>Экспорт Firewall</SheetTitle>
<SheetDescription>RouterOS .rsc · /ip firewall filter, nat, mangle</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<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 isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/50"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</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 ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт Firewall"
description="RouterOS .rsc · /ip firewall filter, nat, mangle"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
code,
},
]}
/>
)
}
+47 -85
View File
@@ -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<GreTunnel | null>(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 (
<div className="flex flex-col h-full">
@@ -551,82 +548,47 @@ export default function GrePage() {
</div>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
{codePreviewTunnel && (() => {
const code = generateRosCommands(codePreviewTunnel, serverById)
return (
<>
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
</div>
<Button
variant="outline" size="sm"
className="shrink-0 gap-1.5"
onClick={() => handleCopy(code)}
>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
: <><CopyIcon className="size-3.5" /> Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* meta strip */}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span>
{codePreviewTunnel.ipsec && (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
</>
)}
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
<CodeExportSheet
open={!!codePreviewTunnel}
onClose={() => setCodePreviewTunnel(null)}
title={codePreviewTunnel?.name ?? "GRE"}
description="Команды RouterOS 7.20+ для создания туннеля"
formats={[
{
id: "rsc",
label: "RouterOS",
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
code: greExportCode,
},
]}
beforeCode={
codePreviewTunnel ? (
<div className="flex flex-wrap gap-3 text-xs shrink-0">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{" → "}
{codePreviewTunnel.remoteAddress}
</span>
{codePreviewTunnel.ipsec ? (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-success">
<LockIcon className="size-3" />
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
</span>
</>
) : null}
</div>
) : null
}
/>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
+80 -58
View File
@@ -16,6 +16,17 @@ import { Separator } from "@/components/ui/separator"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data"
@@ -152,7 +163,7 @@ const PERM_OPTS: { v: PermLevel; label: string }[] = [
const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
write: "bg-success/10 text-success",
}
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
@@ -188,8 +199,8 @@ function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange:
"px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v
? o.v === "write" ? "bg-emerald-600 text-white dark:bg-emerald-500"
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500"
? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted",
)}
@@ -685,68 +696,79 @@ function UserSheet({ open, user, onSave, onClose }: {
// ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({
open,
filename,
busy,
onConfirm,
onCancel,
}: {
open: boolean
filename: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
<AlertCircleIcon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-semibold">Восстановить базу приложения?</p>
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать
актуальный бэкап.
</p>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button>
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}>
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
</AlertDialogMedia>
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<AlertDialogDescription>
Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
Рекомендуется сначала скачать актуальный бэкап.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={onConfirm}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"}
</Button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
function DeleteConfirm({
open,
user,
onConfirm,
onCancel,
}: {
open: boolean
user: User
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
<TrashIcon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-semibold">Удалить пользователя?</p>
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Это действие нельзя отменить. Пользователь потеряет доступ немедленно.
</p>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button>
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button>
</div>
</div>
</div>
<AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<TrashIcon />
</AlertDialogMedia>
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<AlertDialogDescription>
{user.name} · @{user.login}. Это действие нельзя отменить.
Пользователь потеряет доступ немедленно.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -937,7 +959,7 @@ export default function SettingsPage() {
// ── Общие ──
if (section === "Общие") return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
{/* ── Источник данных ── */}
<OpsPanel
@@ -960,9 +982,7 @@ export default function SettingsPage() {
"px-3 text-xs transition-colors border-input",
i === 0 && "border-r",
mode === v
? v === "live"
? "bg-emerald-600 text-white dark:bg-emerald-500"
: "bg-primary text-primary-foreground"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}>
{l}
@@ -972,7 +992,7 @@ export default function SettingsPage() {
</SettingRow>
) : (
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
<span className="inline-flex h-8 items-center rounded-md border border-input bg-emerald-600 px-3 text-xs text-white dark:bg-emerald-500">
<span className="inline-flex h-8 items-center rounded-md border border-input bg-primary px-3 text-xs text-primary-foreground">
Живые
</span>
</SettingRow>
@@ -1173,7 +1193,7 @@ export default function SettingsPage() {
// ── EvoBGP ──
if (section === "EvoBGP") return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
{(mode !== "live" || backendStatus !== true) && (
<Frame dense className="w-full">
<FramePanel className="px-4 py-4">
@@ -1357,7 +1377,7 @@ export default function SettingsPage() {
// ── Уведомления ──
if (section === "Уведомления") return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
@@ -1479,11 +1499,11 @@ export default function SettingsPage() {
// ── API-ключи ──
if (section === "API-ключи") return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3">
{apiKeys.map(k => (
<Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4">
@@ -1546,7 +1566,7 @@ export default function SettingsPage() {
// ── Безопасность ──
if (section === "Безопасность") return (
<div className="space-y-4">
<div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов">
@@ -1647,6 +1667,7 @@ export default function SettingsPage() {
{/* delete confirm */}
{dbRestoreFile && (
<DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name}
busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
@@ -1658,6 +1679,7 @@ export default function SettingsPage() {
)}
{deleteTarget && (
<DeleteConfirm
open={!!deleteTarget}
user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)}
+147 -98
View File
@@ -4,11 +4,30 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
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 { requestJson } from "@/shared/api/http-client"
import { Badge } from "@/components/reui/badge"
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"
import { cn } from "@/lib/utils"
@@ -437,6 +456,7 @@ export default function TerminalPage() {
const [liveServers, setLiveServers] = useState<TermServer[]>([])
const [serversLoading, setServersLoading] = useState(false)
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
const [railOpen, setRailOpen] = useState(false)
// Load servers from backend when in live mode
useEffect(() => {
@@ -492,6 +512,79 @@ export default function TerminalPage() {
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) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
@@ -502,108 +595,42 @@ export default function TerminalPage() {
}
return (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
<>
<Button
size="sm"
variant="outline"
className="md:hidden"
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="grid grid-cols-[220px_1fr] gap-5 h-full">
<div className="flex min-h-0 flex-1">
<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="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 ── */}
<div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
{selected ? (
<Terminal
key={termKey}
@@ -612,12 +639,34 @@ export default function TerminalPage() {
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 ? "Загрузка серверов…" : "Выберите сервер"}
</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>
)
}
+16 -54
View File
@@ -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 (
<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>Экспорт VXLAN</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<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 = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</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 ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт VXLAN"
description="RouterOS 7.x · /interface/vxlan + vteps"
formats={[
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
code,
},
]}
/>
)
}
+450 -140
View File
@@ -10,10 +10,36 @@ import {
WireguardDataGrid,
type WgIfaceWithServer,
} 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 { 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 { 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 { requestJson } from "@/shared/api/http-client"
import {
@@ -31,12 +57,25 @@ import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-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 {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
ServerIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon,
} 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[] {
const result: WgIfaceWithServer[] = []
for (const srv of mockServers) {
@@ -94,7 +133,11 @@ interface BackendServer {
name: string
host: string
country: string
type?: Server["type"]
enabled: boolean
status?: Server["status"]
latency?: number | null
asn?: string
}
function mapBackendServer(s: BackendServer): Server {
@@ -106,11 +149,11 @@ function mapBackendServer(s: BackendServer): Server {
os: "—",
site: "",
country: s.country || "UN",
asn: "",
type: "exit-node",
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: "online",
latency: null,
status: s.status ?? "online",
latency: s.latency ?? null,
sessions: 0,
}
}
@@ -126,20 +169,32 @@ 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() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<WgFailure[]>([])
const [loading, setLoading] = 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 [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
const [liveExport, setLiveExport] = useState<{
rsc?: string
conf?: string
@@ -157,6 +212,7 @@ export default function WireGuardPage() {
])
setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
setFailures(wg.failures ?? [])
if (wg.failures?.length) {
toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
@@ -165,6 +221,7 @@ export default function WireGuardPage() {
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([])
setFailures([])
} finally {
setLoading(false)
}
@@ -175,6 +232,7 @@ export default function WireGuardPage() {
queueMicrotask(() => {
setLiveIfaces([])
setLiveServers([])
setFailures([])
})
return
}
@@ -186,10 +244,35 @@ export default function WireGuardPage() {
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => {
if (!search) return displayIfaces
const effectiveServerId =
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()
return displayIfaces.filter(
return statusFiltered.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.serverName.toLowerCase().includes(q) ||
@@ -199,14 +282,47 @@ export default function WireGuardPage() {
(p.endpoint ?? "").includes(q),
),
)
}, [displayIfaces, search])
}, [statusFiltered, search])
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = displayIfaces.reduce(
const peerRows = useMemo<WgPeerRow[]>(() => {
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,
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) => ({
id: s.id,
@@ -214,6 +330,33 @@ export default function WireGuardPage() {
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) {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
@@ -302,15 +445,26 @@ export default function WireGuardPage() {
}
}
async function handleDelete(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
async function confirmDelete() {
if (!pendingDelete) return
if (!isLive) {
toast.info("Доступно только в live-режиме")
setPendingDelete(null)
return
}
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
toast.success("Удалено")
if (pendingDelete.kind === "iface") {
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()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
@@ -348,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") {
if (!exportIface || !isLive) return
setExportBusy(true)
@@ -372,6 +511,7 @@ export default function WireGuardPage() {
interfaceName: exportIface.name,
format,
includePrivateKey: format !== "peer-conf",
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
})
setLiveExport((prev) => ({
...prev,
@@ -389,12 +529,44 @@ export default function WireGuardPage() {
}
}
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
setLiveExport(null)
setExportInitialTab(tab)
setExportPeerId(peerId ?? null)
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 (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button
size="sm"
variant="outline"
className="md:hidden"
onClick={() => setRailOpen(true)}
>
<ServerIcon className="size-4" />
{selectedLabel}
</Button>
{isLive && (
<Button
size="sm"
@@ -418,120 +590,227 @@ export default function WireGuardPage() {
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
<div className="flex min-h-0 flex-1">
<aside className="hidden min-h-0 w-60 shrink-0 p-3 pr-0 md:flex">
{rail}
</aside>
<div className="min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка WireGuard"
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: scopedIfaces.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",
},
]}
/>
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
</AlertDescription>
</Alert>
) : null}
{!isLive ? (
<Alert variant="info">
<InfoIcon />
<AlertTitle>Mock-режим</AlertTitle>
<AlertDescription>
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
</AlertDescription>
</Alert>
) : null}
<Tabs
value={workspaceTab}
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
className="gap-3"
>
<TabsList variant="line">
<TabsTrigger value="interfaces" className="gap-1.5">
<ShieldCheckIcon className="size-3.5" />
Интерфейсы
</TabsTrigger>
<TabsTrigger value="peers" className="gap-1.5">
<UsersIcon className="size-3.5" />
Пиры
</TabsTrigger>
<TabsTrigger value="cli" className="gap-1.5">
<CodeXmlIcon className="size-3.5" />
CLI
</TabsTrigger>
</TabsList>
<TabsContent value="interfaces" className="mt-0 outline-none">
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
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>
</FramePanel>
</Frame>
))}
</OpsPanel>
</TabsContent>
</Tabs>
</div>
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">
WireGuard live-интеграция RouterOS 7.x
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{isLive
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</p>
</div>
</div>
<DataPageCard>
<DataPageToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, серверу, IP…"
countLabel={`${filtered.length} интерфейсов`}
/>
<WireguardDataGrid
interfaces={filtered}
onExport={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
/>
</DataPageCard>
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
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="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</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
open={createOpen}
onOpenChange={setCreateOpen}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onSubmit={handleCreate}
/>
@@ -539,6 +818,7 @@ export default function WireGuardPage() {
open={importOpen}
onOpenChange={setImportOpen}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onImport={handleImport}
/>
@@ -552,14 +832,44 @@ export default function WireGuardPage() {
<WgExportSheet
open={!!exportIface}
iface={exportIface}
initialTab={exportInitialTab}
peerId={exportPeerId}
onClose={() => {
setExportIface(null)
setExportPeerId(null)
setExportInitialTab("rsc")
setLiveExport(null)
}}
liveContent={liveExport}
liveBusy={exportBusy}
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>
)
}
@@ -8,7 +8,7 @@ import {
} from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils"
import {
CompactDataGrid,
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
errLabel?: string
}) {
return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
<Badge variant="success-outline" size="sm" className="text-[10px]">
{okLabel}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
{errLabel}
</Badge>
)
+28 -19
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo } from "react"
import { useMemo, type ReactNode } from "react"
import {
type ColumnDef,
getCoreRowModel,
@@ -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,
@@ -47,6 +48,8 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
compactServer?: boolean
emptyAction?: ReactNode
onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void
@@ -57,6 +60,8 @@ interface WireguardDataGridProps {
function WireguardDataGrid({
interfaces,
compactServer = false,
emptyAction,
onExport,
onAddPeer,
onToggle,
@@ -70,7 +75,11 @@ function WireguardDataGrid({
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
<DataGridSortHeader
column={column}
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
className="ml-1"
/>
),
cell: ({ row }) => {
const iface = row.original
@@ -88,15 +97,17 @@ 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>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
{!compactServer ? (
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
) : null}
<p className="sr-only">
{onlinePeers}/{iface.peers.length} пиров
</p>
@@ -105,7 +116,7 @@ function WireguardDataGrid({
)
},
meta: {
headerTitle: "Интерфейс / Сервер",
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
@@ -152,7 +163,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 +179,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: "Статус",
@@ -258,7 +266,7 @@ function WireguardDataGrid({
},
},
],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
)
const table = useReactTable({
@@ -276,7 +284,8 @@ function WireguardDataGrid({
<EmptyState
icon={<ShieldCheckIcon className="size-4" />}
title="Нет WireGuard интерфейсов"
description="Добавьте первый интерфейс или проверьте поиск"
description="Добавьте первый интерфейс или сбросьте фильтры"
action={emptyAction}
className="border-0 py-16"
/>
)
@@ -74,18 +74,18 @@ function WireGuardPeersDetail({
<span
className={cn(
"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 ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
<ArrowDownIcon className="size-3 text-success" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
<ArrowUpIcon className="size-3 text-info" />
{fmtBytes(peer.transferTx)}
</span>
</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 }
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import Link from "next/link"
import { Badge } from "@/components/ui/badge"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
+284
View File
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
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, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} 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
/** Content came from live device fetch */
isLive?: boolean
}
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-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground/90"
}
function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
{lines.map((line, i) => (
<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
/** Short CTA — keep laconic */
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
* Alert: https://reui.io/docs/components/base/alert
*/
function CodeExportSheet({
open,
onClose,
title,
description,
formats,
initialFormatId,
onLiveFetch,
liveBusy,
liveLabel = "С роутера",
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
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
function handleCopy() {
if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
className={cn(
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
className,
)}
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
{showTabs ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v))}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{onLiveFetch || beforeCode ? (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{onLiveFetch ? (
<Badge
variant={isLive ? "success-light" : "secondary"}
size="sm"
radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={liveBusy}
onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
>
{liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel}
</Button>
) : null}
</div>
) : null}
<Frame dense className="flex min-h-0 flex-1 flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? (
<div className="flex flex-1 items-center p-4">
<Alert variant="warning" className="w-full">
<TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription>
</Alert>
</div>
) : (
<ScrollArea className="h-full min-h-0 flex-1">
<div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea>
)}
</FramePanel>
</Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть
</SheetClose>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="default"
disabled={!canCopy}
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-4" />
Файл
</Button>
<Button
type="button"
size="default"
disabled={!canCopy}
onClick={handleCopy}
className="min-w-28"
>
{copied ? (
<CheckIcon className="size-4 text-success" />
) : (
<CopyIcon className="size-4" />
)}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { CodeExportSheet, downloadText }
+5
View File
@@ -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"
+12
View File
@@ -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"
}
+307
View File
@@ -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}`
}
+236
View File
@@ -0,0 +1,236 @@
"use client"
import { useMemo, useState, type ReactNode } from "react"
import type { ServerStatus, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import {
Frame,
FrameHeader,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
export const ALL_SERVERS_ID = "all"
export interface ServerTileItem {
id: string
name: string
country?: string
status?: ServerStatus
type?: ServerType
count?: number
enabled?: boolean
selectable?: boolean
title?: string
}
function typeBadgeVariant(
type: ServerType,
): "focus-light" | "info-light" | "success-light" {
if (type === "jump-host") return "focus-light"
if (type === "home-router") return "success-light"
return "info-light"
}
function typeBadgeLabel(type: ServerType): string {
if (type === "jump-host") return "JH"
if (type === "home-router") return "HR"
return "EN"
}
function ServerTypeBadge({ type }: { type: ServerType }) {
return (
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
{typeBadgeLabel(type)}
</Badge>
)
}
function matchesQuery(item: ServerTileItem, q: string): boolean {
if (!q) return true
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
return hay.includes(q)
}
function ServerTileRail({
items,
selectedId,
onSelect,
allCount = 0,
showHeader = true,
showAll = true,
showCount = true,
showType = true,
headerRight,
className,
}: {
items: ServerTileItem[]
selectedId: string
onSelect: (id: string) => void
allCount?: number
showHeader?: boolean
showAll?: boolean
showCount?: boolean
showType?: boolean
headerRight?: ReactNode
className?: string
}) {
const [query, setQuery] = useState("")
const q = query.trim().toLowerCase()
const filtered = useMemo(
() => items.filter((item) => matchesQuery(item, q)),
[items, q],
)
const showAllTile = showAll && !q
return (
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
{showHeader ? (
<div className="flex items-center justify-between gap-2">
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Серверы
</FrameTitle>
{headerRight}
</div>
) : headerRight ? (
<div className="flex justify-end">{headerRight}</div>
) : null}
<InputGroup>
<InputGroupAddon>
<SearchIcon className="size-3.5" />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск…"
aria-label="Поиск сервера"
/>
</InputGroup>
</FrameHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
{showAllTile ? (
<ServerTileButton
selected={selectedId === ALL_SERVERS_ID}
onSelect={() => onSelect(ALL_SERVERS_ID)}
title="Все серверы"
name="Все"
count={showCount ? allCount : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
<LayersIcon className="text-muted-foreground" />
</IconTile>
}
/>
) : null}
{filtered.map((item) => (
<ServerTileButton
key={item.id}
selected={selectedId === item.id}
onSelect={() => onSelect(item.id)}
title={item.title ?? item.name}
name={item.name}
count={showCount ? item.count : undefined}
enabled={item.enabled}
selectable={item.selectable}
status={item.status}
type={showType ? item.type : undefined}
icon={
<IconTile variant="elevated" size="sm" aria-hidden="true">
{item.country ? (
<Flag code={item.country} size={16} />
) : (
<ServerIcon className="text-muted-foreground" />
)}
</IconTile>
}
/>
))}
{filtered.length === 0 && !showAllTile ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
</p>
) : null}
</div>
</ScrollArea>
</FramePanel>
</Frame>
)
}
function ServerTileButton({
selected,
onSelect,
title,
name,
count,
enabled = true,
selectable = true,
status,
type,
icon,
}: {
selected: boolean
onSelect: () => void
title: string
name: string
count?: number
enabled?: boolean
selectable?: boolean
status?: ServerStatus
type?: ServerType
icon: ReactNode
}) {
return (
<button
type="button"
role="option"
aria-selected={selected}
title={title}
disabled={!selectable}
onClick={onSelect}
className={cn(
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
selected
? "bg-muted ring-1 ring-border"
: "hover:bg-muted/60",
!enabled && !selected && "opacity-40",
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
)}
>
{icon}
<span className="flex min-w-0 flex-1 items-center gap-1.5">
{status ? <StatusDot status={status} /> : null}
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
{type ? <ServerTypeBadge type={type} /> : null}
</span>
{count != null ? (
<Badge
variant={selected ? "secondary" : "outline"}
size="xs"
className="tabular-nums"
>
{count}
</Badge>
) : null}
</button>
)
}
export { ServerTileRail, ServerTypeBadge }
+9 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -51,30 +51,31 @@ function WgCreateSheet({
onOpenChange,
servers,
busy,
defaultServerId,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
useEffect(() => {
if (!open) return
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
}, [open, defaultServerId])
const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort])
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<Sheet open={open} onOpenChange={onOpenChange}>
<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">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
+88 -160
View File
@@ -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: ".rsc", filename: "wg.rsc", code: "" },
{ id: "conf", label: ".conf", filename: "wg.conf", code: "" },
{
id: "peer",
label: "Peer",
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,77 @@ 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: ".rsc",
filename: `${iface.name}.rsc`,
code: liveContent?.rsc ?? localRsc,
isLive: Boolean(liveContent?.rsc),
},
{
id: "conf",
label: ".conf",
filename: `${iface.name}.conf`,
code: liveContent?.conf ?? localConf,
isLive: Boolean(liveContent?.conf),
},
{
id: "peer",
label: "Peer",
filename: `${iface.name}-peer.conf`,
code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
isLive: Boolean(liveContent?.peerConf),
},
]
}, [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}
liveLabel="С роутера"
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
}
/>
)
}
+11 -2
View File
@@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import {
@@ -17,12 +17,14 @@ function WgImportSheet({
onOpenChange,
servers,
busy,
defaultServerId,
onImport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
defaultServerId?: string
onImport: (args: {
serverId: string
content: string
@@ -41,6 +43,11 @@ function WgImportSheet({
[content],
)
useEffect(() => {
if (!open) return
setServerId(defaultServerId ?? "")
}, [open, defaultServerId])
function runPreview() {
setParseError(null)
setPreview(null)
@@ -66,7 +73,9 @@ function WgImportSheet({
<Sheet
open={open}
onOpenChange={(v) => {
if (!v) {
if (v) {
setServerId(defaultServerId ?? "")
} else {
setContent("")
setPreview(null)
setParseError(null)
+79
View File
@@ -0,0 +1,79 @@
# UI Design Contract (MikrotikManager)
Surface: **ReUI Frame**. Kit: `components/reui-kit/`.
Иерархия: **ReUI PRO > shadcn primitives**.
Стек: Next.js 16 App Router + Turbopack + shadcn **base-nova** + ReUI `@reui`.
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
## Surface
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как page shell. Не смешивать Card и Frame на одном ops-экране.
Эталон CRUD: [`app/(main)/servers`](../app/(main)/servers).
Оболочка списков: `DataPageCard` → Frame. Панели: `OpsPanel` → Frame.
## Canonical PRO references
| Зона | Preview |
|------|---------|
| Shell | https://reui.io/preview/base/app-shell-12 |
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Kit (`components/reui-kit/`)
| Component | Role |
|-----------|------|
| `KpiStatGrid` | Hybrid KPI (IconTile elevated `size-10.5`) |
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
Слои:
| Слой | Путь |
|------|------|
| shadcn | `components/ui/` |
| ReUI CLI | `components/reui/` |
| Kit | `components/reui-kit/` |
| Domain grids | `components/data-grids/` |
## Shared App Shell chrome
| Токен / зона | Значение |
|--------------|----------|
| `--sidebar-width` | `240px` |
| Header right | AppsMenu → SystemMonitorPopover (тема — в NavUser) |
| Search | ⌘K / Ctrl+K only |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
## Spacing & tokens
- `flex` + `gap-*` — не `space-y-*` / `space-x-*`
- Semantic tokens / ReUI Badge variants — не raw `bg-emerald-*` / hex
- Max 1 primary CTA на экран
## Exceptions (не Frame ops shell)
- `network-map` — canvas / topology UX
- `terminal` — terminal chrome
## Forbidden
- Card как ops list/dashboard shell
- Hand-roll data-grid / KPI / code-export при наличии kit
- Дубль Copy в header + footer export sheet
- Mixing Card и Frame на одном ops-экране
- Radix-варианты docs — только Base UI (`base-nova`)
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`components.json``@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
+15
View File
@@ -13875,6 +13875,21 @@
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long