Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0c16e808 | ||
|
|
66509b26bd |
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: "Статус",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertAction,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { CheckIcon, CopyIcon, DownloadIcon, InfoIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type CodeExportFormat = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
filename: string
|
||||||
|
code: string
|
||||||
|
/** Empty / unavailable — show warning instead of code */
|
||||||
|
emptyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightLine(line: string): string {
|
||||||
|
const trimmed = line.trimStart()
|
||||||
|
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
|
||||||
|
if (trimmed.startsWith("[")) return "text-info"
|
||||||
|
if (trimmed.startsWith("/")) return "text-info"
|
||||||
|
if (/^\s+[a-z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-primary"
|
||||||
|
return "text-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ code }: { code: string }) {
|
||||||
|
const lines = code.length ? code.split("\n") : [""]
|
||||||
|
return (
|
||||||
|
<pre className="px-4 py-4 text-[12px] font-mono leading-relaxed whitespace-pre-wrap break-all select-all">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span key={i} className={cn("block", highlightLine(line))}>
|
||||||
|
{line || " "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeExportSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
formats: CodeExportFormat[]
|
||||||
|
/** Initial format id when sheet opens */
|
||||||
|
initialFormatId?: string
|
||||||
|
/** Optional live fetch from device */
|
||||||
|
onLiveFetch?: (formatId: string) => void
|
||||||
|
liveBusy?: boolean
|
||||||
|
liveLabel?: string
|
||||||
|
className?: string
|
||||||
|
/** Extra content under format tabs (e.g. meta strip) */
|
||||||
|
beforeCode?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared code export Sheet — ReUI Frame + sticky footer.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
function CodeExportSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
formats,
|
||||||
|
initialFormatId,
|
||||||
|
onLiveFetch,
|
||||||
|
liveBusy,
|
||||||
|
liveLabel = "Подтянуть с роутера (с private-key)",
|
||||||
|
className,
|
||||||
|
beforeCode,
|
||||||
|
}: CodeExportSheetProps) {
|
||||||
|
const firstId = formats[0]?.id ?? "default"
|
||||||
|
const [tab, setTab] = useState(initialFormatId ?? firstId)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setTab(initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||||
|
? initialFormatId
|
||||||
|
: firstId)
|
||||||
|
setCopied(false)
|
||||||
|
}, [open, initialFormatId, firstId, formats])
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => formats.find((f) => f.id === tab) ?? formats[0],
|
||||||
|
[formats, tab],
|
||||||
|
)
|
||||||
|
|
||||||
|
const code = active?.code ?? ""
|
||||||
|
const emptyMessage = active?.emptyMessage
|
||||||
|
const filename = active?.filename ?? "export.txt"
|
||||||
|
const canCopy = Boolean(code) && !emptyMessage
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (!canCopy) return
|
||||||
|
void navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTabs = formats.length > 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<SheetContent
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12">
|
||||||
|
<SheetTitle>{title}</SheetTitle>
|
||||||
|
{description ? (
|
||||||
|
<SheetDescription>{description}</SheetDescription>
|
||||||
|
) : null}
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 px-6 pt-3">
|
||||||
|
{showTabs ? (
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(String(v))}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsTrigger key={f.id} value={f.id}>
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsContent key={f.id} value={f.id} className="mt-0 hidden" />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onLiveFetch ? (
|
||||||
|
<Alert variant="info" className="shrink-0">
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>Live с роутера</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Подтянуть актуальный конфиг с private-key с устройства.
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={liveBusy}
|
||||||
|
onClick={() => onLiveFetch(tab)}
|
||||||
|
>
|
||||||
|
{liveBusy ? (
|
||||||
|
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{liveBusy ? "Загрузка…" : liveLabel}
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{beforeCode}
|
||||||
|
|
||||||
|
<Frame dense className="min-h-0 flex-1 flex flex-col">
|
||||||
|
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||||
|
{emptyMessage ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>Нет данных</AlertTitle>
|
||||||
|
<AlertDescription>{emptyMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full min-h-[12rem] max-h-[min(60vh,28rem)]">
|
||||||
|
<CodeBlock code={code} />
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={() => downloadText(filename, code)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
Файл
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CodeExportSheet, downloadText }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CodeExportSheet, downloadText } from "./code-export-sheet"
|
||||||
|
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
|
||||||
|
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||||
|
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||||
|
export { kpiCols } from "./kpi-cols"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Shared grid column classes for hybrid KPI tiles.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return "grid-cols-1"
|
||||||
|
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
|
||||||
|
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
|
||||||
|
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||||
|
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
|
||||||
|
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { kpiCols } from "./kpi-cols"
|
||||||
|
|
||||||
|
export type KpiStatVariant = "default" | "warning" | "destructive"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
href?: string
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: "text-foreground",
|
||||||
|
warning: "text-warning",
|
||||||
|
destructive: "text-destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||||
|
return item.onClick ?? item.onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(item: KpiStatItem): boolean {
|
||||||
|
return Boolean(item.selected ?? item.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === "string") {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.href || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
"relative isolate flex h-full min-w-0 flex-col",
|
||||||
|
clickable &&
|
||||||
|
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
|
||||||
|
selected && "ring-primary/30 bg-muted/30 ring-1",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn("grid gap-2", kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
embedded?: boolean
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn("w-full", className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
|
if (item.id) return item.id
|
||||||
|
if (typeof item.label === "string") return item.label
|
||||||
|
return `kpi-${index}`
|
||||||
|
}
|
||||||
@@ -1,29 +1,16 @@
|
|||||||
"use client"
|
"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: "MikroTik .rsc", filename: "wg.rsc", code: "" },
|
||||||
if (open) {
|
{ id: "conf", label: "Native .conf", filename: "wg.conf", code: "" },
|
||||||
setTab("rsc")
|
{
|
||||||
setCopied(false)
|
id: "peer",
|
||||||
|
label: "Peer .conf",
|
||||||
|
filename: "wg-peer.conf",
|
||||||
|
code: "",
|
||||||
|
emptyMessage: "Интерфейс не выбран",
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}, [open, iface?.id])
|
|
||||||
|
|
||||||
const local = useMemo(() => {
|
|
||||||
if (!iface) return { rsc: "", conf: "", peerConf: "" }
|
|
||||||
const base = {
|
const base = {
|
||||||
name: iface.name,
|
name: iface.name,
|
||||||
listenPort: iface.listenPort,
|
listenPort: iface.listenPort,
|
||||||
@@ -76,143 +70,73 @@ 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: "MikroTik .rsc",
|
||||||
? (liveContent?.conf ?? local.conf)
|
filename: `${iface.name}.rsc`,
|
||||||
: (liveContent?.peerConf ?? local.peerConf)
|
code: liveContent?.rsc ?? localRsc,
|
||||||
|
},
|
||||||
const filename =
|
{
|
||||||
tab === "rsc"
|
id: "conf",
|
||||||
? `${iface?.name ?? "wg"}.rsc`
|
label: "Native .conf",
|
||||||
: tab === "conf"
|
filename: `${iface.name}.conf`,
|
||||||
? `${iface?.name ?? "wg"}.conf`
|
code: liveContent?.conf ?? localConf,
|
||||||
: `${iface?.name ?? "wg"}-peer.conf`
|
},
|
||||||
|
{
|
||||||
function handleCopy() {
|
id: "peer",
|
||||||
void navigator.clipboard.writeText(code).then(() => {
|
label: "Peer .conf",
|
||||||
setCopied(true)
|
filename: `${iface.name}-peer.conf`,
|
||||||
setTimeout(() => setCopied(false), 2000)
|
code: liveContent?.peerConf ?? peerConf,
|
||||||
})
|
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
|
||||||
}
|
},
|
||||||
|
]
|
||||||
|
}, [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>
|
onLiveFetch={
|
||||||
</div>
|
onRequestLiveExport
|
||||||
<div className="flex gap-2 shrink-0">
|
? (formatId) =>
|
||||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
onRequestLiveExport(
|
||||||
{copied
|
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
|
||||||
? <><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>
|
|
||||||
)
|
)
|
||||||
})}
|
: 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`.
|
||||||
Reference in New Issue
Block a user