Compare commits

...
3 Commits
Author SHA1 Message Date
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
20 changed files with 1095 additions and 641 deletions
+16 -49
View File
@@ -17,13 +17,14 @@ import {
import { import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose, SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
function ExportSheet({ open, container, onClose }: { function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void open: boolean; container: RouterContainer | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт Container"
<div> description="RouterOS 7.4+ · /container · /interface/veth"
<SheetTitle>Экспорт Container</SheetTitle> formats={[
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${container?.name ?? "container"}.rsc`,
</Button> code,
</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>
) )
} }
+28 -28
View File
@@ -4,7 +4,7 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { IconTile } from "@/components/reui/icon-tile"
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") { if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot const t = snap as TrafficRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{t.skipped ? ( {t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null} ) : null}
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") { if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot const u = snap as ResourcesRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{u.skipped ? ( {u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null} ) : null}
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") { if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot const s = snap as ServersRestPingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{s.skipped ? ( {s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null} ) : null}
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") { if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot const p = snap as PingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{p.skipped ? ( {p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null} ) : null}
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") { if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot const s = snap as SpeedScheduledRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы
</p> </p>
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") { if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot const g = snap as GreBgpSnapshotRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{g.skipped ? ( {g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null} ) : null}
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{g.errors?.length ? ( {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) => ( {g.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") { if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot const p = snap as InternetPathRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок internet-path на{" "} Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> <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") { if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot const c = snap as CertificatesRenewRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{c.skipped ? ( {c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null} ) : null}
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{c.errors.length ? ( {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) => ( {c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") { if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot const b = snap as BackupsRunSnapshot
return ( 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"> <dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Слот расписания</dt> <dt className="text-muted-foreground">Слот расписания</dt>
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{b.errors?.length ? ( {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) => ( {b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") { if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot const a = snap as AlertEngineRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок на{" "} Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span> <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 ? ( {a.errors?.length ? (
<Alert variant="destructive" className="py-2"> <Alert variant="destructive" className="py-2">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs"> <AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => ( {a.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert> </Alert>
) : null} ) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? ( {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> <p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent"> <DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} /> <AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return ( 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"> <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> <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> <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>
<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> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd> <dd className="font-mono text-xs">{r.jobKey}</dd>
</div> </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> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd> <dd>
<span className="font-medium">{jobTitle}</span> <span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p> <p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd> </dd>
</div> </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> <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> <dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div> </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> <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> <dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div> </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> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums"> <dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс <span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span> <span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd> </dd>
</div> </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> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd> <dd>
<Badge <Badge
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div> </div>
</dl> </dl>
{r.error ? ( {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> <p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre <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" 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 /> <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> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? ( {snapshot ? (
<SnapshotTables snap={snapshot} /> <SnapshotTables snap={snapshot} />
@@ -1090,7 +1090,7 @@ export default function DataCollectionPage() {
</div> </div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} /> <DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator /> <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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p> <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} {schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика Сохранить настройки планировщика
</Button> </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> <p>
Трафик последний сбор:{" "} Трафик последний сбор:{" "}
{trafficCollector?.lastCollectedAt {trafficCollector?.lastCollectedAt
+20 -89
View File
@@ -35,6 +35,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner" import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
@@ -754,13 +755,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[] tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]> recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) { }) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo( const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId), () => rulesets.filter(r => r.serverId === serverId),
[rulesets, 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 totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo( const config = useMemo(
@@ -771,93 +770,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer], [open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
) )
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <CodeExportSheet
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} /> open={open}
<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]"> onClose={onClose}
title="RouterOS config"
{/* header */} description={
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0"> server
<div className="flex items-center gap-2.5"> ? `${server.name} · ${totalRules} правил`
<FileCodeIcon className="size-4 text-muted-foreground" /> : "Экспорт правил фильтрации"
<span className="text-sm font-semibold">RouterOS config</span> }
{server && ( formats={[
<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} /> id: "rsc",
<span className="font-mono">{server.name}</span> label: "RouterOS",
<span>· {totalRules} правил</span> filename: `filters-${server?.name ?? "server"}.rsc`,
</span> code: config,
)} },
</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>
) )
} }
+18 -57
View File
@@ -34,12 +34,13 @@ import { cn } from "@/lib/utils"
import { import {
PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon, CodeXmlIcon,
CheckIcon, PowerIcon, CheckCircleIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -602,68 +603,28 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
function ExportSheet({ open, onClose, rules }: { function ExportSheet({ open, onClose, rules }: {
open: boolean; onClose: () => void; rules: FirewallRule[] 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( const timestamp = useMemo(
() => open ? new Date().toLocaleString("ru") : undefined, () => open ? new Date().toLocaleString("ru") : undefined,
[open], [open],
) )
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) 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 ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт Firewall"
<div> description="RouterOS .rsc · /ip firewall filter, nat, mangle"
<SheetTitle>Экспорт Firewall</SheetTitle> formats={[
<SheetDescription>RouterOS .rsc · /ip firewall filter, nat, mangle</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> code,
: <><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>
) )
} }
+47 -85
View File
@@ -31,9 +31,10 @@ import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, DatabaseIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── label maps ───────────────────────────────────────────────────────────── // ─── label maps ─────────────────────────────────────────────────────────────
@@ -244,7 +245,6 @@ export default function GrePage() {
const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null) const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm) const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm) const [pForm, setPForm] = useState(defaultPoolForm)
@@ -366,13 +366,10 @@ export default function GrePage() {
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
] ]
function handleCopy(code: string) { const greExportCode = useMemo(
navigator.clipboard.writeText(code).then(() => { () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
setCopied(true) [codePreviewTunnel, serverById],
toast.success("Команды скопированы") )
setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -551,82 +548,47 @@ export default function GrePage() {
</div> </div>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} {/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}> <CodeExportSheet
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0"> open={!!codePreviewTunnel}
{codePreviewTunnel && (() => { onClose={() => setCodePreviewTunnel(null)}
const code = generateRosCommands(codePreviewTunnel, serverById) title={codePreviewTunnel?.name ?? "GRE"}
return ( description="Команды RouterOS 7.20+ для создания туннеля"
<> formats={[
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> {
<div className="flex items-start justify-between gap-4"> id: "rsc",
<div> label: "RouterOS",
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle> filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription> code: greExportCode,
</div> },
<Button ]}
variant="outline" size="sm" beforeCode={
className="shrink-0 gap-1.5" codePreviewTunnel ? (
onClick={() => handleCopy(code)} <div className="flex flex-wrap gap-3 text-xs shrink-0">
> <span className="flex items-center gap-1.5">
{copied <span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</> {STATUS_MAP[codePreviewTunnel.status].label}
: <><CopyIcon className="size-3.5" /> Копировать</>} </span>
</Button> <span className="text-muted-foreground">·</span>
</div> <span>{serverById[codePreviewTunnel.serverId]?.name}</span>
</SheetHeader> <span className="text-muted-foreground">·</span>
<span className="font-mono">
<div className="flex-1 overflow-y-auto"> {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{/* meta strip */} {" → "}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs"> {codePreviewTunnel.remoteAddress}
<span className="flex items-center gap-1.5"> </span>
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} /> {codePreviewTunnel.ipsec ? (
{STATUS_MAP[codePreviewTunnel.status].label} <>
</span> <span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">·</span> <span className="flex items-center gap-1 text-success">
<span>{serverById[codePreviewTunnel.serverId]?.name}</span> <LockIcon className="size-3" />
<span className="text-muted-foreground">·</span> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span> </span>
{codePreviewTunnel.ipsec && ( </>
<> ) : null}
<span className="text-muted-foreground">·</span> </div>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span> ) : null
</> }
)} />
</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>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}> <Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
+80 -58
View File
@@ -16,6 +16,17 @@ import { Separator } from "@/components/ui/separator"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet" } 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 { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data" import { servers } from "@/lib/data"
@@ -152,7 +163,7 @@ const PERM_OPTS: { v: PermLevel; label: string }[] = [
const PERM_COLOR: Record<PermLevel, string> = { const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground", none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400", 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 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", "px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input", i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v value === o.v
? o.v === "write" ? "bg-emerald-600 text-white dark:bg-emerald-500" ? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500" : o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white" : "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted", : "text-muted-foreground hover:bg-muted",
)} )}
@@ -685,68 +696,79 @@ function UserSheet({ open, user, onSave, onClose }: {
// ─── delete confirm ─────────────────────────────────────────────────────────── // ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({ function DatabaseRestoreConfirm({
open,
filename, filename,
busy, busy,
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
open: boolean
filename: string filename: string
busy?: boolean busy?: boolean
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<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"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <AlertCircleIcon />
<AlertCircleIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Восстановить базу приложения?</p> Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p> Рекомендуется сначала скачать актуальный бэкап.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать <AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
актуальный бэкап. <AlertDialogAction
</p> variant="destructive"
<div className="flex gap-2"> disabled={busy}
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button> onClick={onConfirm}
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}> >
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null} {busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"} {busy ? "Восстановление…" : "Восстановить"}
</Button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<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"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <TrashIcon />
<TrashIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Удалить пользователя?</p> {user.name} · @{user.login}. Это действие нельзя отменить.
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p> Пользователь потеряет доступ немедленно.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Это действие нельзя отменить. Пользователь потеряет доступ немедленно. <AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
</p> <AlertDialogAction variant="destructive" onClick={onConfirm}>
<div className="flex gap-2"> Удалить
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button> </AlertDialogAction>
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
</div>
) )
} }
@@ -937,7 +959,7 @@ export default function SettingsPage() {
// ── Общие ── // ── Общие ──
if (section === "Общие") return ( if (section === "Общие") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
{/* ── Источник данных ── */} {/* ── Источник данных ── */}
<OpsPanel <OpsPanel
@@ -960,9 +982,7 @@ export default function SettingsPage() {
"px-3 text-xs transition-colors border-input", "px-3 text-xs transition-colors border-input",
i === 0 && "border-r", i === 0 && "border-r",
mode === v mode === v
? v === "live" ? "bg-primary text-primary-foreground"
? "bg-emerald-600 text-white dark:bg-emerald-500"
: "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted", : "text-muted-foreground hover:bg-muted",
)}> )}>
{l} {l}
@@ -972,7 +992,7 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
) : ( ) : (
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда"> <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> </span>
</SettingRow> </SettingRow>
@@ -1173,7 +1193,7 @@ export default function SettingsPage() {
// ── EvoBGP ── // ── EvoBGP ──
if (section === "EvoBGP") return ( if (section === "EvoBGP") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
{(mode !== "live" || backendStatus !== true) && ( {(mode !== "live" || backendStatus !== true) && (
<Frame dense className="w-full"> <Frame dense className="w-full">
<FramePanel className="px-4 py-4"> <FramePanel className="px-4 py-4">
@@ -1357,7 +1377,7 @@ export default function SettingsPage() {
// ── Уведомления ── // ── Уведомления ──
if (section === "Уведомления") return ( if (section === "Уведомления") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5"> <OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
<SettingRow label="Email" description="Отправка уведомлений на [email protected]"> <SettingRow label="Email" description="Отправка уведомлений на [email protected]">
<FormToggle checked={notifEmail} onChange={setNotifEmail} /> <FormToggle checked={notifEmail} onChange={setNotifEmail} />
@@ -1479,11 +1499,11 @@ export default function SettingsPage() {
// ── API-ключи ── // ── API-ключи ──
if (section === "API-ключи") return ( if (section === "API-ключи") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button> <Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
</div> </div>
<div className="space-y-3"> <div className="flex flex-col gap-3">
{apiKeys.map(k => ( {apiKeys.map(k => (
<Frame key={k.id} dense className="w-full"> <Frame key={k.id} dense className="w-full">
<FramePanel className="pt-4 pb-3 px-4"> <FramePanel className="pt-4 pb-3 px-4">
@@ -1546,7 +1566,7 @@ export default function SettingsPage() {
// ── Безопасность ── // ── Безопасность ──
if (section === "Безопасность") return ( if (section === "Безопасность") return (
<div className="space-y-4"> <div className="flex flex-col gap-4">
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5"> <OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
<SettingRow label="Двухфакторная аутентификация (MFA)" <SettingRow label="Двухфакторная аутентификация (MFA)"
description="TOTP / Authenticator app для всех администраторов"> description="TOTP / Authenticator app для всех администраторов">
@@ -1647,6 +1667,7 @@ export default function SettingsPage() {
{/* delete confirm */} {/* delete confirm */}
{dbRestoreFile && ( {dbRestoreFile && (
<DatabaseRestoreConfirm <DatabaseRestoreConfirm
open={!!dbRestoreFile}
filename={dbRestoreFile.name} filename={dbRestoreFile.name}
busy={dbRestoreBusy} busy={dbRestoreBusy}
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }} onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
@@ -1658,6 +1679,7 @@ export default function SettingsPage() {
)} )}
{deleteTarget && ( {deleteTarget && (
<DeleteConfirm <DeleteConfirm
open={!!deleteTarget}
user={deleteTarget} user={deleteTarget}
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }} onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
+16 -54
View File
@@ -13,13 +13,9 @@ import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
NetworkIcon, PlusIcon, CopyIcon, CheckIcon, NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react" } from "lucide-react"
import { import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -70,57 +66,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
function ExportSheet({ open, tunnel, onClose }: { function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт VXLAN"
<div> description="RouterOS 7.x · /interface/vxlan + vteps"
<SheetTitle>Экспорт VXLAN</SheetTitle> formats={[
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${tunnel?.name ?? "vxlan"}.rsc`,
</Button> code,
</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>
) )
} }
+66 -45
View File
@@ -11,8 +11,12 @@ import {
type WgIfaceWithServer, type WgIfaceWithServer,
} from "@/components/data-grids/wireguard-data-grid" } from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame" import {
import { IconTile } from "@/components/reui/icon-tile" Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel" import { OpsPanel } from "@/components/ops-panel"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
@@ -34,7 +38,7 @@ import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-pee
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
} from "lucide-react" } from "lucide-react"
function collectMockInterfaces(): WgIfaceWithServer[] { function collectMockInterfaces(): WgIfaceWithServer[] {
@@ -139,6 +143,8 @@ export default function WireGuardPage() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null) 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 [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [liveExport, setLiveExport] = useState<{ const [liveExport, setLiveExport] = useState<{
rsc?: string rsc?: string
@@ -372,6 +378,7 @@ export default function WireGuardPage() {
interfaceName: exportIface.name, interfaceName: exportIface.name,
format, format,
includePrivateKey: format !== "peer-conf", includePrivateKey: format !== "peer-conf",
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
}) })
setLiveExport((prev) => ({ setLiveExport((prev) => ({
...prev, ...prev,
@@ -389,6 +396,13 @@ export default function WireGuardPage() {
} }
} }
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
setLiveExport(null)
setExportInitialTab(tab)
setExportPeerId(peerId ?? null)
setExportIface(iface)
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PageHeader <PageHeader
@@ -420,40 +434,49 @@ export default function WireGuardPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <KpiStatGrid
{[ aria-label="Сводка WireGuard"
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> }, items={[
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> }, {
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> }, id: "ifaces",
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> }, label: "Интерфейсов",
].map((s) => ( value: displayIfaces.length,
<Frame key={s.label} className="h-full"> icon: <ShieldCheckIcon className="size-4" />,
<FramePanel className="relative isolate flex h-full items-start gap-3"> iconClassName: "text-muted-foreground",
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground"> },
{s.icon} {
</IconTile> id: "up",
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> label: "Активных (UP)",
<p className="text-muted-foreground text-sm font-medium">{s.label}</p> value: upIfaces,
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p> icon: <ActivityIcon className="size-4" />,
</div> iconClassName: "text-success",
</FramePanel> },
</Frame> {
))} id: "peers",
</div> 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",
},
]}
/>
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm"> <Alert variant="info">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" /> <InfoIcon />
<div> <AlertTitle>WireGuard live-интеграция RouterOS 7.x</AlertTitle>
<p className="font-medium text-sky-600 dark:text-sky-400"> <AlertDescription>
WireGuard live-интеграция RouterOS 7.x {isLive
</p> ? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
<p className="text-muted-foreground text-xs mt-0.5"> : "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
{isLive </AlertDescription>
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера." </Alert>
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</p>
</div>
</div>
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
@@ -464,18 +487,12 @@ export default function WireGuardPage() {
/> />
<WireguardDataGrid <WireguardDataGrid
interfaces={filtered} interfaces={filtered}
onExport={(iface) => { onExport={(iface) => openExport(iface, "rsc")}
setLiveExport(null)
setExportIface(iface)
}}
onAddPeer={setPeerIface} onAddPeer={setPeerIface}
onToggle={handleToggle} onToggle={handleToggle}
onDelete={handleDelete} onDelete={handleDelete}
onDeletePeer={handleDeletePeer} onDeletePeer={handleDeletePeer}
onExportPeer={(iface) => { onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
setLiveExport(null)
setExportIface(iface)
}}
/> />
</DataPageCard> </DataPageCard>
@@ -518,7 +535,7 @@ export default function WireGuardPage() {
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide"> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title} {b.title}
</p> </p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto"> <pre className="bg-muted rounded-md p-2.5 text-muted-foreground text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")} {b.lines.join("\n")}
</pre> </pre>
</div> </div>
@@ -552,8 +569,12 @@ export default function WireGuardPage() {
<WgExportSheet <WgExportSheet
open={!!exportIface} open={!!exportIface}
iface={exportIface} iface={exportIface}
initialTab={exportInitialTab}
peerId={exportPeerId}
onClose={() => { onClose={() => {
setExportIface(null) setExportIface(null)
setExportPeerId(null)
setExportInitialTab("rsc")
setLiveExport(null) setLiveExport(null)
}} }}
liveContent={liveExport} liveContent={liveExport}
@@ -8,7 +8,7 @@ import {
} from "@tanstack/react-table" } from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings" import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
+3 -3
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
CompactDataGrid, CompactDataGrid,
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
errLabel?: string errLabel?: string
}) { }) {
return ok ? ( 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} {okLabel}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive"> <Badge variant="destructive-outline" size="sm" className="text-[10px]">
{errLabel} {errLabel}
</Badge> </Badge>
) )
+8 -10
View File
@@ -11,6 +11,7 @@ import {
import type { WireGuardInterface } from "@/lib/data" import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -88,7 +89,7 @@ function WireguardDataGrid({
<span <span
className={cn( className={cn(
"size-2 rounded-full shrink-0", "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> <span className="font-mono font-semibold text-sm">{iface.name}</span>
@@ -152,7 +153,7 @@ function WireguardDataGrid({
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return ( return (
<span className="font-mono text-sm text-center block"> <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 className="text-muted-foreground">/{iface.peers.length}</span>
</span> </span>
) )
@@ -168,16 +169,13 @@ function WireguardDataGrid({
accessorKey: "status", accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />, header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => ( cell: ({ row }) => (
<span <Badge
className={cn( variant={row.original.status === "up" ? "success-light" : "destructive-light"}
"text-[11px] font-mono px-2 py-0.5 rounded border", size="sm"
row.original.status === "up" className="font-mono"
? "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",
)}
> >
{row.original.status === "up" ? "UP" : "DOWN"} {row.original.status === "up" ? "UP" : "DOWN"}
</span> </Badge>
), ),
meta: { meta: {
headerTitle: "Статус", headerTitle: "Статус",
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, 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}`
}
+88 -160
View File
@@ -1,29 +1,16 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useMemo } from "react"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid" import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, CodeExportSheet,
SheetDescription, SheetFooter, SheetClose, type CodeExportFormat,
} from "@/components/ui/sheet" } from "@/components/reui-kit/code-export-sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { import {
generateMikrotikRsc, generateMikrotikRsc,
generateNativeConf, generateNativeConf,
generatePeerClientConf, generatePeerClientConf,
} from "@/lib/wg-config" } 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({ function WgExportSheet({
open, open,
@@ -32,27 +19,34 @@ function WgExportSheet({
liveContent, liveContent,
liveBusy, liveBusy,
onRequestLiveExport, onRequestLiveExport,
initialTab = "rsc",
peerId,
}: { }: {
open: boolean open: boolean
iface: WgIfaceWithServer | null iface: WgIfaceWithServer | null
onClose: () => void onClose: () => void
/** Optional server-fetched content (with private key) keyed by format */
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
liveBusy?: boolean liveBusy?: boolean
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void 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 formats = useMemo((): CodeExportFormat[] => {
const [copied, setCopied] = useState(false) if (!iface) {
return [
useEffect(() => { { id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
if (open) { { id: "conf", label: ".conf", filename: "wg.conf", code: "" },
setTab("rsc") {
setCopied(false) id: "peer",
label: "Peer",
filename: "wg-peer.conf",
code: "",
emptyMessage: "Интерфейс не выбран",
},
]
} }
}, [open, iface?.id])
const local = useMemo(() => {
if (!iface) return { rsc: "", conf: "", peerConf: "" }
const base = { const base = {
name: iface.name, name: iface.name,
listenPort: iface.listenPort, listenPort: iface.listenPort,
@@ -76,143 +70,77 @@ function WgExportSheet({
clientEndpoint: p.clientEndpoint, clientEndpoint: p.clientEndpoint,
})), })),
} }
const peer = iface.peers[0]
return { const peer =
rsc: generateMikrotikRsc(base), (peerId
conf: generateNativeConf(base), ? iface.peers.find((p) => p.id === peerId || p.rosId === peerId)
peerConf: : undefined) ?? iface.peers[0]
iface.publicKey && peer
? generatePeerClientConf({ const localRsc = generateMikrotikRsc(base)
peerAddress: peer.clientAddress, const localConf = generateNativeConf(base)
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey, let peerConf = ""
allowedIps: peer.allowedIps, let peerEmpty: string | undefined
endpoint: if (!iface.publicKey) {
peer.clientEndpoint || peerEmpty = "Нет public-key интерфейса для клиентского .conf"
peer.endpoint || } else if (!peer) {
undefined, peerEmpty = "Нет пиров для экспорта Peer .conf"
persistentKeepalive: peer.persistentKeepalive ?? 25, } else {
}) peerConf = generatePeerClientConf({
: "# Нет public-key интерфейса или пиров для клиентского .conf\n", 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 = return [
tab === "rsc" {
? (liveContent?.rsc ?? local.rsc) id: "rsc",
: tab === "conf" label: ".rsc",
? (liveContent?.conf ?? local.conf) filename: `${iface.name}.rsc`,
: (liveContent?.peerConf ?? local.peerConf) code: liveContent?.rsc ?? localRsc,
isLive: Boolean(liveContent?.rsc),
const filename = },
tab === "rsc" {
? `${iface?.name ?? "wg"}.rsc` id: "conf",
: tab === "conf" label: ".conf",
? `${iface?.name ?? "wg"}.conf` filename: `${iface.name}.conf`,
: `${iface?.name ?? "wg"}-peer.conf` code: liveContent?.conf ?? localConf,
isLive: Boolean(liveContent?.conf),
function handleCopy() { },
void navigator.clipboard.writeText(code).then(() => { {
setCopied(true) id: "peer",
setTimeout(() => setCopied(false), 2000) label: "Peer",
}) filename: `${iface.name}-peer.conf`,
} code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
isLive: Boolean(liveContent?.peerConf),
},
]
}, [iface, liveContent, peerId])
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт WireGuard"
<div> description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
<SheetTitle>Экспорт WireGuard</SheetTitle> formats={formats}
<SheetDescription> initialFormatId={initialTab}
{iface ? `${iface.name} · ${iface.serverName}` : "—"} liveBusy={liveBusy}
</SheetDescription> liveLabel="С роутера"
</div> onLiveFetch={
<div className="flex gap-2 shrink-0"> onRequestLiveExport
<Button variant="outline" size="sm" onClick={handleCopy}> ? (formatId) =>
{copied onRequestLiveExport(
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
: <><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>
) )
})} : undefined
</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>
) )
} }
+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": { "dependencies": {
"zod": "^4.4.1" "zod": "^4.4.1"
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+1 -1
View File
File diff suppressed because one or more lines are too long