Compare commits

...
6 Commits
Author SHA1 Message Date
DenozordecandCursor 5e0c16e808 fix(ui): выровнять confirms и токены под ReUI PRO
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 40s
Docker images / publish-release (push) Successful in 10s
Добавить ui-design-contract, AlertDialog вместо hand-roll, CodeExportSheet для preview фильтров, ReUI Badge и gap вместо space-y.

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

Co-authored-by: Cursor <[email protected]>
2026-09-06 00:12:50 +07:00
Denozordec 15ad53af1f feat(wireguard): implement WireGuard interface management and permissions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 10s
Added comprehensive support for managing WireGuard interfaces, including CRUD operations and peer management. Updated permissions to include access control for WireGuard routes. Enhanced the UI components to display and interact with WireGuard configurations, improving user experience and functionality. Introduced new tests for WireGuard-related functionalities to ensure reliability.
2026-09-05 02:10:55 +07:00
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
52 changed files with 4229 additions and 885 deletions
+2 -2
View File
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers" import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
Stepper, Stepper,
@@ -276,8 +277,7 @@ export default function BackupsPage() {
} }
async function handleDownload(id: string, fallbackFilename: string) { async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`) const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const blob = await res.blob() const blob = await res.blob()
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
+2 -5
View File
@@ -23,6 +23,7 @@ import {
XIcon, AlertCircleIcon, XIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── types ──────────────────────────────────────────────────────────────────── // ─── types ────────────────────────────────────────────────────────────────────
@@ -621,11 +622,7 @@ export default function BgpPage() {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`) void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
.then(data => { .then(data => {
if (cancelled) return if (cancelled) return
setLiveSessions(data.map(backendToFrontend)) setLiveSessions(data.map(backendToFrontend))
+16 -49
View File
@@ -17,13 +17,14 @@ import {
import { import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon, BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon, MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon, CodeXmlIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon, TerminalIcon, AlertCircleIcon,
} from "lucide-react" } from "lucide-react"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose, SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
function ExportSheet({ open, container, onClose }: { function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void open: boolean; container: RouterContainer | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container]) const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт Container"
<div> description="RouterOS 7.4+ · /container · /interface/veth"
<SheetTitle>Экспорт Container</SheetTitle> formats={[
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${container?.name ?? "container"}.rsc`,
</Button> code,
</div> },
</SheetHeader> ]}
<div className="flex-1 overflow-y-auto"> />
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
+28 -28
View File
@@ -4,7 +4,7 @@ import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile" import { IconTile } from "@/components/reui/icon-tile"
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") { if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot const t = snap as TrafficRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{t.skipped ? ( {t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null} ) : null}
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_resources") { if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot const u = snap as ResourcesRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{u.skipped ? ( {u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null} ) : null}
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "servers_rest_ping") { if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot const s = snap as ServersRestPingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{s.skipped ? ( {s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null} ) : null}
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_ping") { if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot const p = snap as PingRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{p.skipped ? ( {p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null} ) : null}
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "uptime_speed") { if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot const s = snap as SpeedScheduledRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> по очереди для каждой включённой пробы
</p> </p>
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "gre_bgp") { if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot const g = snap as GreBgpSnapshotRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{g.skipped ? ( {g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null} ) : null}
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{g.errors?.length ? ( {g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => ( {g.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "internet_path") { if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot const p = snap as InternetPathRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок internet-path на{" "} Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "certificates_renew") { if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot const c = snap as CertificatesRenewRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
{c.skipped ? ( {c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p> <p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null} ) : null}
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{c.errors.length ? ( {c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => ( {c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "backups") { if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot const b = snap as BackupsRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4"> <dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div> <div>
<dt className="text-muted-foreground">Слот расписания</dt> <dt className="text-muted-foreground">Слот расписания</dt>
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div> </div>
</dl> </dl>
{b.errors?.length ? ( {b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1"> <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => ( {b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p> <p key={i} className="break-words">{e}</p>
))} ))}
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "alert_engine") { if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot const a = snap as AlertEngineRunSnapshot
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Снимок на{" "} Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span> <span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{a.errors?.length ? ( {a.errors?.length ? (
<Alert variant="destructive" className="py-2"> <Alert variant="destructive" className="py-2">
<AlertCircleIcon /> <AlertCircleIcon />
<AlertDescription className="space-y-1 text-xs"> <AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => ( {a.errors.map((e, i) => (
<p key={i} className="break-words"> <p key={i} className="break-words">
{e} {e}
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</Alert> </Alert>
) : null} ) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? ( {a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2"> <div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p> <p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent"> <DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} /> <AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
return ( return (
<div className="space-y-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3"> <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd> <dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd> <dd className="font-mono text-xs">{r.jobKey}</dd>
</div> </div>
<div className="sm:col-span-2 space-y-1"> <div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd> <dd>
<span className="font-medium">{jobTitle}</span> <span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p> <p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd> </dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd> <dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd> <dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums"> <dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс <span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span> <span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd> </dd>
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt> <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd> <dd>
<Badge <Badge
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
</div> </div>
</dl> </dl>
{r.error ? ( {r.error ? (
<div className="space-y-1.5"> <div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p> <p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre <pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto" className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
<Separator /> <Separator />
<div className="space-y-2"> <div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? ( {snapshot ? (
<SnapshotTables snap={snapshot} /> <SnapshotTables snap={snapshot} />
@@ -1090,7 +1090,7 @@ export default function DataCollectionPage() {
</div> </div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} /> <DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator /> <Separator />
<div className="space-y-3 px-5 py-4"> <div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p> <p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
@@ -1142,7 +1142,7 @@ export default function DataCollectionPage() {
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null} {schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить настройки планировщика Сохранить настройки планировщика
</Button> </Button>
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60"> <div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p> <p>
Трафик последний сбор:{" "} Трафик последний сбор:{" "}
{trafficCollector?.lastCollectedAt {trafficCollector?.lastCollectedAt
+20 -89
View File
@@ -35,6 +35,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { toast } from "sonner" import { toast } from "sonner"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ── helpers ──────────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────────
@@ -754,13 +755,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
tunnelsList: GreTunnel[] tunnelsList: GreTunnel[]
recRoutesByServer: Record<string, RecursiveRouteLite[]> recRoutesByServer: Record<string, RecursiveRouteLite[]>
}) { }) {
const [copied, setCopied] = useState(false)
const singleRuleset = useMemo( const singleRuleset = useMemo(
() => rulesets.filter(r => r.serverId === serverId), () => rulesets.filter(r => r.serverId === serverId),
[rulesets, serverId], [rulesets, serverId],
) )
const server = serversList.find(s => s.id === serverId) const server = serversList.find(s => s.id === serverId)
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0) const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
const config = useMemo( const config = useMemo(
@@ -771,93 +770,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer], [open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
) )
const handleCopy = () => {
navigator.clipboard.writeText(config).catch(() => {})
setCopied(true); setTimeout(() => setCopied(false), 2000)
}
if (!open) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <CodeExportSheet
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} /> open={open}
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]"> onClose={onClose}
title="RouterOS config"
{/* header */} description={
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0"> server
<div className="flex items-center gap-2.5"> ? `${server.name} · ${totalRules} правил`
<FileCodeIcon className="size-4 text-muted-foreground" /> : "Экспорт правил фильтрации"
<span className="text-sm font-semibold">RouterOS config</span> }
{server && ( formats={[
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full"> {
<Flag code={server.country} size={11} /> id: "rsc",
<span className="font-mono">{server.name}</span> label: "RouterOS",
<span>· {totalRules} правил</span> filename: `filters-${server?.name ?? "server"}.rsc`,
</span> code: config,
)} },
</div> ]}
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button> />
</div>
{/* code */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
{config.split("\n").map((line, i) => {
const trimmed = line.trimStart()
const cls =
// section dividers
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
? "text-[#444c56]" :
// inline comments inside rule body
trimmed.startsWith("# ")
? "text-[#8b949e]" :
// RouterOS scripting keywords
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
? "text-[#d2a8ff]" :
// :if identity branch / closing brace
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
? "text-[#ff7b72] font-semibold" :
// filter rule add command
trimmed.startsWith("/routing filter rule")
? "text-[#79c0ff]" :
// rule body: if/else if branches
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
? "text-[#ff7b72]" :
// rule body: blackhole action
trimmed.startsWith("set type blackhole")
? "text-[#ff7b72] font-semibold" :
// rule body: set actions
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
? "text-[#a5d6ff]" :
// rule body: accept / rule close
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
? "text-[#79c0ff]" :
// named params
trimmed.match(/^(chain|comment|bgp-communities)=/)
? "text-[#a5d6ff]" :
"text-[#c9d1d9]"
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
})}
</pre>
</div>
{/* footer */}
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
<p>1. Скопируйте скрипт в буфер обмена</p>
<p>3. Вставьте в терминал и нажмите Enter</p>
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
<p>4. Скрипт сам определит свои правила по identity</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
<Button onClick={handleCopy} className="flex-1">
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано!" : "Скопировать"}
</Button>
</div>
</div>
</div>
</div>
) )
} }
+18 -57
View File
@@ -34,12 +34,13 @@ import { cn } from "@/lib/utils"
import { import {
PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon, CodeXmlIcon,
CheckIcon, PowerIcon, CheckCircleIcon, PowerIcon, CheckCircleIcon,
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -602,68 +603,28 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
function ExportSheet({ open, onClose, rules }: { function ExportSheet({ open, onClose, rules }: {
open: boolean; onClose: () => void; rules: FirewallRule[] open: boolean; onClose: () => void; rules: FirewallRule[]
}) { }) {
const [copied, setCopied] = useState(false)
// Derived from open — new timestamp each time modal opens, undefined when closed
const timestamp = useMemo( const timestamp = useMemo(
() => open ? new Date().toLocaleString("ru") : undefined, () => open ? new Date().toLocaleString("ru") : undefined,
[open], [open],
) )
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
function handleCopy() {
const full = generateRsc(rules, new Date().toLocaleString("ru"))
navigator.clipboard.writeText(full).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт Firewall"
<div> description="RouterOS .rsc · /ip firewall filter, nat, mangle"
<SheetTitle>Экспорт Firewall</SheetTitle> formats={[
<SheetDescription>RouterOS .rsc · /ip firewall filter, nat, mangle</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> code,
: <><CopyIcon className="size-3.5" />Копировать</>} },
</Button> ]}
</div> />
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/50"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
+47 -85
View File
@@ -31,9 +31,10 @@ import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, DatabaseIcon,
} from "lucide-react" } from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
// ─── label maps ───────────────────────────────────────────────────────────── // ─── label maps ─────────────────────────────────────────────────────────────
@@ -244,7 +245,6 @@ export default function GrePage() {
const [tunnelOpen, setTunnelOpen] = useState(false) const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null) const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm) const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm) const [pForm, setPForm] = useState(defaultPoolForm)
@@ -366,13 +366,10 @@ export default function GrePage() {
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
] ]
function handleCopy(code: string) { const greExportCode = useMemo(
navigator.clipboard.writeText(code).then(() => { () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
setCopied(true) [codePreviewTunnel, serverById],
toast.success("Команды скопированы") )
setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -551,82 +548,47 @@ export default function GrePage() {
</div> </div>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} {/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}> <CodeExportSheet
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0"> open={!!codePreviewTunnel}
{codePreviewTunnel && (() => { onClose={() => setCodePreviewTunnel(null)}
const code = generateRosCommands(codePreviewTunnel, serverById) title={codePreviewTunnel?.name ?? "GRE"}
return ( description="Команды RouterOS 7.20+ для создания туннеля"
<> formats={[
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0"> {
<div className="flex items-start justify-between gap-4"> id: "rsc",
<div> label: "RouterOS",
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle> filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription> code: greExportCode,
</div> },
<Button ]}
variant="outline" size="sm" beforeCode={
className="shrink-0 gap-1.5" codePreviewTunnel ? (
onClick={() => handleCopy(code)} <div className="flex flex-wrap gap-3 text-xs shrink-0">
> <span className="flex items-center gap-1.5">
{copied <span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</> {STATUS_MAP[codePreviewTunnel.status].label}
: <><CopyIcon className="size-3.5" /> Копировать</>} </span>
</Button> <span className="text-muted-foreground">·</span>
</div> <span>{serverById[codePreviewTunnel.serverId]?.name}</span>
</SheetHeader> <span className="text-muted-foreground">·</span>
<span className="font-mono">
<div className="flex-1 overflow-y-auto"> {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
{/* meta strip */} {" → "}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs"> {codePreviewTunnel.remoteAddress}
<span className="flex items-center gap-1.5"> </span>
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} /> {codePreviewTunnel.ipsec ? (
{STATUS_MAP[codePreviewTunnel.status].label} <>
</span> <span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">·</span> <span className="flex items-center gap-1 text-success">
<span>{serverById[codePreviewTunnel.serverId]?.name}</span> <LockIcon className="size-3" />
<span className="text-muted-foreground">·</span> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span> </span>
{codePreviewTunnel.ipsec && ( </>
<> ) : null}
<span className="text-muted-foreground">·</span> </div>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span> ) : null
</> }
)} />
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}> <Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
+10 -9
View File
@@ -16,6 +16,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
@@ -733,13 +734,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings() const ra = readStoredRouteOptimizerSettings()
setOptimizing(true) setOptimizing(true)
try { try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, { const data = await requestJson<BackendOspfOptimizeResponse>(
method: "POST", backendUrl,
headers: { "Content-Type": "application/json" }, `/api/servers/${filterServerId}/ospf/optimize`,
body: JSON.stringify({ pingWeight: ra.pingWeight }), {
}) method: "POST",
if (!r.ok) throw new Error(`HTTP ${r.status}`) body: JSON.stringify({ pingWeight: ra.pingWeight }),
const data = await r.json() as BackendOspfOptimizeResponse },
)
const byKey: Record<string, number> = {} const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => { data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
if (cancelled) return if (cancelled) return
setLoading(true) setLoading(true)
setLiveError(null) setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`) void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
.then(data => { .then(data => {
if (cancelled) return if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false) setLiveData(data); setFetchedAt(new Date()); setLoading(false)
+84 -59
View File
@@ -16,6 +16,17 @@ import { Separator } from "@/components/ui/separator"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
import { servers } from "@/lib/data" import { servers } from "@/lib/data"
@@ -152,7 +163,7 @@ const PERM_OPTS: { v: PermLevel; label: string }[] = [
const PERM_COLOR: Record<PermLevel, string> = { const PERM_COLOR: Record<PermLevel, string> = {
none: "bg-muted text-muted-foreground", none: "bg-muted text-muted-foreground",
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400", read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", write: "bg-success/10 text-success",
} }
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
@@ -188,8 +199,8 @@ function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange:
"px-2 text-[11px] font-medium transition-colors", "px-2 text-[11px] font-medium transition-colors",
i < PERM_OPTS.length - 1 && "border-r border-input", i < PERM_OPTS.length - 1 && "border-r border-input",
value === o.v value === o.v
? o.v === "write" ? "bg-emerald-600 text-white dark:bg-emerald-500" ? o.v === "write" ? "bg-success text-white"
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500" : o.v === "read" ? "bg-info text-white"
: "bg-muted-foreground/70 text-white" : "bg-muted-foreground/70 text-white"
: "text-muted-foreground hover:bg-muted", : "text-muted-foreground hover:bg-muted",
)} )}
@@ -685,68 +696,79 @@ function UserSheet({ open, user, onSave, onClose }: {
// ─── delete confirm ─────────────────────────────────────────────────────────── // ─── delete confirm ───────────────────────────────────────────────────────────
function DatabaseRestoreConfirm({ function DatabaseRestoreConfirm({
open,
filename, filename,
busy, busy,
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
open: boolean
filename: string filename: string
busy?: boolean busy?: boolean
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
}) { }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <AlertCircleIcon />
<AlertCircleIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Восстановить базу приложения?</p> Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p> Рекомендуется сначала скачать актуальный бэкап.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать <AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
актуальный бэкап. <AlertDialogAction
</p> variant="destructive"
<div className="flex gap-2"> disabled={busy}
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button> onClick={onConfirm}
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}> >
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null} {busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
{busy ? "Восстановление…" : "Восстановить"} {busy ? "Восстановление…" : "Восстановить"}
</Button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
) )
} }
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) { function DeleteConfirm({
open,
user,
onConfirm,
onCancel,
}: {
open: boolean
user: User
onConfirm: () => void
onCancel: () => void
}) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <AlertDialogContent size="default">
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4"> <AlertDialogHeader>
<div className="flex items-center gap-3"> <AlertDialogMedia className="bg-destructive/10 text-destructive">
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0"> <TrashIcon />
<TrashIcon className="size-4 text-destructive" /> </AlertDialogMedia>
</div> <AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
<div> <AlertDialogDescription>
<p className="text-sm font-semibold">Удалить пользователя?</p> {user.name} · @{user.login}. Это действие нельзя отменить.
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p> Пользователь потеряет доступ немедленно.
</div> </AlertDialogDescription>
</div> </AlertDialogHeader>
<p className="text-xs text-muted-foreground"> <AlertDialogFooter>
Это действие нельзя отменить. Пользователь потеряет доступ немедленно. <AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
</p> <AlertDialogAction variant="destructive" onClick={onConfirm}>
<div className="flex gap-2"> Удалить
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button> </AlertDialogAction>
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
</div>
) )
} }
@@ -875,8 +897,11 @@ export default function SettingsPage() {
await evo.saveSettings(patch) await evo.saveSettings(patch)
setEvoKeyDraft("") setEvoKeyDraft("")
markSaved() markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) { } catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения") const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally { } finally {
setEvoSaveBusy(false) setEvoSaveBusy(false)
} }
@@ -934,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
@@ -957,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}
@@ -969,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>
@@ -1170,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">
@@ -1354,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} />
@@ -1476,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">
@@ -1543,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 для всех администраторов">
@@ -1644,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() }}
@@ -1655,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)}
+14 -12
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
@@ -259,12 +260,14 @@ function Terminal({
if (isLive && server.backendId !== null) { if (isLive && server.backendId !== null) {
setExecuting(true) setExecuting(true)
try { try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, { const data = await requestJson<{ output?: string; error?: string }>(
method: "POST", backendUrl,
headers: { "Content-Type": "application/json" }, `/api/servers/${server.backendId}/exec`,
body: JSON.stringify({ command: cmd }), {
}) method: "POST",
const data = await res.json() as { output?: string; error?: string } body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)" const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output" const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line => text.split("\n").forEach(line =>
@@ -427,7 +430,7 @@ interface BackendServer {
} }
export default function TerminalPage() { export default function TerminalPage() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live" const isLive = mode === "live"
// Server list state // Server list state
@@ -437,14 +440,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode // Load servers from backend when in live mode
useEffect(() => { useEffect(() => {
if (!isLive) return if (!isLive || !prefsHydrated) return
let cancelled = false let cancelled = false
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return if (cancelled) return
setServersLoading(true) setServersLoading(true)
fetch(`${backendUrl}/api/servers`) void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then(r => r.json() as Promise<BackendServer[]>) .then((data) => {
.then(data => {
if (cancelled) return if (cancelled) return
setLiveServers(data.map(s => ({ setLiveServers(data.map(s => ({
uid: String(s.id), uid: String(s.id),
@@ -462,7 +464,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) }) .catch(() => { if (!cancelled) setServersLoading(false) })
}) })
return () => { cancelled = true } return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey]) }, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers() const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
+16 -54
View File
@@ -13,13 +13,9 @@ import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
NetworkIcon, PlusIcon, CopyIcon, CheckIcon, NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react" } from "lucide-react"
import { import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
@@ -70,57 +66,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
function ExportSheet({ open, tunnel, onClose }: { function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) { }) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel]) const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <CodeExportSheet
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl"> open={open}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b"> onClose={onClose}
<div className="flex items-start justify-between gap-4"> title="Экспорт VXLAN"
<div> description="RouterOS 7.x · /interface/vxlan + vteps"
<SheetTitle>Экспорт VXLAN</SheetTitle> formats={[
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription> {
</div> id: "rsc",
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}> label: "MikroTik .rsc",
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>} filename: `${tunnel?.name ?? "vxlan"}.rsc`,
</Button> code,
</div> },
</SheetHeader> ]}
<div className="flex-1 overflow-y-auto"> />
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) )
} }
+502 -193
View File
@@ -1,9 +1,9 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data" import { servers as mockServers } from "@/lib/data"
import type { WireGuardInterface } from "@/lib/data" import type { Server } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card" import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { import {
@@ -11,25 +11,39 @@ 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 { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { cn } from "@/lib/utils"
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, Alert,
SheetDescription, SheetFooter, SheetClose, AlertDescription,
} from "@/components/ui/sheet" AlertTitle,
} from "@/components/reui/alert"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
createWireGuardInterface,
createWireGuardPeer,
deleteWireGuardInterface,
deleteWireGuardPeer,
exportWireGuard,
importWireGuard,
listWireGuard,
patchWireGuardInterface,
} from "@/shared/api/wireguard"
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet"
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
import { toast } from "sonner"
import { import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon, ShieldCheckIcon, PlusIcon, KeyRoundIcon,
CodeXmlIcon, UsersIcon, ActivityIcon, UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
CopyIcon, CheckIcon,
} from "lucide-react" } from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ──────────────────────── function collectMockInterfaces(): WgIfaceWithServer[] {
function collectInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = [] const result: WgIfaceWithServer[] = []
for (const srv of servers) { for (const srv of mockServers) {
for (const wg of srv.wireGuardIfaces ?? []) { for (const wg of srv.wireGuardIfaces ?? []) {
result.push({ result.push({
...wg, ...wg,
@@ -42,117 +56,352 @@ function collectInterfaces(): WgIfaceWithServer[] {
return result return result
} }
// ─── helpers ────────────────────────────────────────────────────────────────── function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
return {
// ─── RSC generator ──────────────────────────────────────────────────────────── id: d.id,
rosId: d.rosId,
function generateWgRsc(iface: WgIfaceWithServer): string { name: d.name,
const lines: string[] = [] listenPort: d.listenPort,
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`) mtu: d.mtu,
lines.push(`# RouterOS 7.x`) publicKey: d.publicKey,
lines.push(``) privateKey: d.privateKey,
lines.push(`/interface wireguard add \\`) address: d.address,
lines.push(` name=${iface.name} \\`) peers: d.peers.map((p) => ({
lines.push(` listen-port=${iface.listenPort} \\`) id: p.id,
lines.push(` mtu=${iface.mtu} \\`) rosId: p.rosId,
if (iface.comment) lines.push(` comment="${iface.comment}" \\`) publicKey: p.publicKey,
if (!iface.enabled) lines.push(` disabled=yes \\`) allowedIps: p.allowedIps,
lines.push(``) endpoint: p.endpoint,
for (const p of iface.peers) { latestHandshake: p.latestHandshake,
lines.push(`/interface wireguard peers add \\`) transferRx: p.transferRx,
lines.push(` interface=${iface.name} \\`) transferTx: p.transferTx,
lines.push(` public-key="${p.publicKey}" \\`) persistentKeepalive: p.persistentKeepalive,
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`) persistent: p.persistent,
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`) comment: p.comment,
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`) disabled: p.disabled,
if (p.persistent) lines.push(` persistent-keepalive=25 \\`) name: p.name,
if (p.comment) lines.push(` comment="${p.comment}" \\`) clientAddress: p.clientAddress,
lines.push(``) clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
comment: d.comment,
enabled: d.enabled,
status: d.status,
serverId: d.serverId,
serverName: d.serverName,
serverCountry: d.serverCountry ?? "UN",
} }
return lines.join("\n")
} }
// ─── Export Sheet ───────────────────────────────────────────────────────────── interface BackendServer {
id: number
name: string
host: string
country: string
enabled: boolean
}
function ExportSheet({ open, iface, onClose }: { function mapBackendServer(s: BackendServer): Server {
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void return {
}) { id: String(s.id),
const [copied, setCopied] = useState(false) name: s.name || s.host,
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface]) host: s.host,
model: "—",
os: "—",
site: "",
country: s.country || "UN",
asn: "",
type: "exit-node",
enabled: s.enabled,
status: "online",
latency: null,
sessions: 0,
}
}
function handleCopy() { function parseEndpoint(endpoint: string): { address?: string; port?: number } {
navigator.clipboard.writeText(code).then(() => { const t = endpoint.trim()
setCopied(true); setTimeout(() => setCopied(false), 2000) if (!t) return {}
}) const idx = t.lastIndexOf(":")
if (idx <= 0) return { address: t }
return {
address: t.slice(0, idx),
port: Number.parseInt(t.slice(idx + 1), 10) || undefined,
} }
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface")
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>
)
} }
// ════════════════════════════════════════════════════════════════════════════
export default function WireGuardPage() { export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), []) const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = 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 [liveExport, setLiveExport] = useState<{
rsc?: string
conf?: string
peerConf?: string
} | null>(null)
const [exportBusy, setExportBusy] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
try {
const [wg, servers] = await Promise.all([
listWireGuard(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
if (wg.failures?.length) {
toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveIfaces([])
setLiveServers([])
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search) return allIfaces if (!search) return displayIfaces
const q = search.toLowerCase() const q = search.toLowerCase()
return allIfaces.filter((i) => return displayIfaces.filter(
i.name.includes(q) || (i) =>
i.serverName.toLowerCase().includes(q) || i.name.toLowerCase().includes(q) ||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q)) i.serverName.toLowerCase().includes(q) ||
i.peers.some(
(p) =>
p.allowedIps.some((a) => a.includes(q)) ||
(p.endpoint ?? "").includes(q),
),
) )
}, [allIfaces, search]) }, [displayIfaces, search])
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0) const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0) const onlinePeers = displayIfaces.reduce(
const upIfaces = allIfaces.filter((i) => i.status === "up").length (s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
0,
)
const upIfaces = displayIfaces.filter((i) => i.status === "up").length
const serverOptions = displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
}))
async function handleCreate(form: WgCreateFormState) {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
return
}
setBusy(true)
try {
const ep = parseEndpoint(form.peerEndpoint)
await createWireGuardInterface(backendUrl, {
serverId: form.serverId,
name: form.name.trim(),
listenPort: Number.parseInt(form.listenPort, 10) || 13231,
mtu: Number.parseInt(form.mtu, 10) || 1420,
comment: form.comment || undefined,
address: form.address.trim() || undefined,
disabled: !form.enabled,
peer: form.peerEnabled && form.peerPublicKey.trim()
? {
publicKey: form.peerPublicKey.trim(),
allowedAddresses: form.peerAllowedIps
.split(",")
.map((s) => s.trim())
.filter(Boolean),
endpointAddress: ep.address,
endpointPort: ep.port,
persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined,
comment: form.peerComment || undefined,
}
: undefined,
})
toast.success(`Интерфейс ${form.name} создан`)
setCreateOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка создания")
} finally {
setBusy(false)
}
}
async function handleImport(args: {
serverId: string
content: string
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) {
if (!isLive) {
toast.info("Импорт на роутер доступен только в live-режиме")
return
}
setBusy(true)
try {
const res = await importWireGuard(backendUrl, {
serverId: args.serverId,
content: args.content,
format: args.format,
dryRun: args.dryRun,
})
toast.success(
res.applied
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
: "Импорт выполнен",
)
setImportOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
} finally {
setBusy(false)
}
}
async function handleToggle(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
try {
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
disabled: iface.enabled,
})
toast.success(iface.enabled ? "Отключено" : "Включено")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleDelete(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
toast.success("Удалено")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
}
}
async function handleAddPeer(form: WgPeerFormState) {
if (!isLive || !peerIface) {
toast.info("Доступно только в live-режиме")
return
}
setBusy(true)
try {
const ep = parseEndpoint(form.endpoint)
await createWireGuardPeer(backendUrl, {
serverId: peerIface.serverId,
interfaceName: peerIface.name,
publicKey: form.publicKey.trim(),
allowedAddresses: form.allowedIps
.split(",")
.map((s) => s.trim())
.filter(Boolean),
endpointAddress: ep.address,
endpointPort: ep.port,
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
comment: form.comment || undefined,
})
toast.success("Пир добавлен")
setPeerIface(null)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
} finally {
setBusy(false)
}
}
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
if (!isLive) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm("Удалить пира?")) return
try {
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
toast.success("Пир удалён")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return
setExportBusy(true)
try {
const res = await exportWireGuard(backendUrl, {
serverId: exportIface.serverId,
interfaceName: exportIface.name,
format,
includePrivateKey: format !== "peer-conf",
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
})
setLiveExport((prev) => ({
...prev,
...(format === "rsc"
? { rsc: res.content }
: format === "conf"
? { conf: res.content }
: { peerConf: res.content }),
}))
toast.success("Конфиг загружен с роутера")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
} finally {
setExportBusy(false)
}
}
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">
@@ -160,8 +409,24 @@ export default function WireGuardPage() {
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]} crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={ actions={
<> <>
<Button size="sm"> {isLive && (
<PlusIcon className="size-4" />Новый интерфейс <Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button> </Button>
</> </>
} }
@@ -169,42 +434,50 @@ 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">
<KpiStatGrid
aria-label="Сводка WireGuard"
items={[
{
id: "ifaces",
label: "Интерфейсов",
value: displayIfaces.length,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: upIfaces,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: totalPeers,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Пиров онлайн",
value: `${onlinePeers}/${totalPeers}`,
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{/* KPI */} <Alert variant="info">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <InfoIcon />
{[ <AlertTitle>WireGuard live-интеграция RouterOS 7.x</AlertTitle>
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> }, <AlertDescription>
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> }, {isLive
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> }, ? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> }, : "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
].map((s) => ( </AlertDescription>
<Frame key={s.label} className="h-full"> </Alert>
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard рекомендуемый туннельный протокол в RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
</p>
</div>
</div>
{/* Search + table */}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
search={search} search={search}
@@ -214,63 +487,99 @@ export default function WireGuardPage() {
/> />
<WireguardDataGrid <WireguardDataGrid
interfaces={filtered} interfaces={filtered}
onExport={setExportIface} onExport={(iface) => openExport(iface, "rsc")}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)}
/> />
</DataPageCard> </DataPageCard>
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4"> <OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[ {[
{ {
title: "Создать интерфейс", title: "Создать интерфейс",
lines: [ lines: [
"/interface wireguard add \\", "/interface wireguard add \\",
" name=wg0 \\", " name=wg0 \\",
" listen-port=13231 \\", " listen-port=13231 \\",
" mtu=1420", " mtu=1420",
], ],
}, },
{ {
title: "Добавить пира", title: "Добавить пира",
lines: [ lines: [
"/interface wireguard peers add \\", "/interface wireguard peers add \\",
" interface=wg0 \\", " interface=wg0 \\",
' public-key="<ключ>" \\', ' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\", " allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\", " endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25", " persistent-keepalive=25",
], ],
}, },
{ {
title: "Назначить IP", title: "Назначить IP",
lines: [ lines: [
"/ip address add \\", "/ip address add \\",
" address=10.210.0.1/30 \\", " address=10.210.0.1/30 \\",
" interface=wg0", " interface=wg0",
"", "",
"# Статус:", "# Статус:",
"/interface wireguard print", "/interface wireguard print",
], ],
}, },
].map((b) => ( ].map((b) => (
<div key={b.title}> <div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p> <p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto"> {b.title}
{b.lines.join("\n")} </p>
</pre> <pre className="bg-muted rounded-md p-2.5 text-muted-foreground text-[11px] leading-relaxed overflow-x-auto">
</div> {b.lines.join("\n")}
))} </pre>
</div> </div>
))}
</div>
</OpsPanel> </OpsPanel>
</div> </div>
</div> </div>
<ExportSheet <WgCreateSheet
open={createOpen}
onOpenChange={setCreateOpen}
servers={serverOptions}
busy={busy}
onSubmit={handleCreate}
/>
<WgImportSheet
open={importOpen}
onOpenChange={setImportOpen}
servers={serverOptions}
busy={busy}
onImport={handleImport}
/>
<WgPeerSheet
open={!!peerIface}
iface={peerIface}
busy={busy}
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
onSubmit={handleAddPeer}
/>
<WgExportSheet
open={!!exportIface} open={!!exportIface}
iface={exportIface} iface={exportIface}
onClose={() => setExportIface(null)} initialTab={exportInitialTab}
peerId={exportPeerId}
onClose={() => {
setExportIface(null)
setExportPeerId(null)
setExportInitialTab("rsc")
setLiveExport(null)
}}
liveContent={liveExport}
liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined}
/> />
</div> </div>
) )
+2 -1
View File
@@ -11,7 +11,8 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts" "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
+2
View File
@@ -23,6 +23,7 @@ import backupsRoutes from "./routes/backups.js"
import certificatesRoutes from "./routes/certificates.js" import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js" import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js" import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
export async function buildApp(opts?: { export async function buildApp(opts?: {
@@ -103,6 +104,7 @@ export async function buildApp(opts?: {
await app.register(certificatesRoutes, { prefix: "/api" }) await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" }) await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" }) await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) { if (opts?.startScheduler !== false) {
refreshScheduler() refreshScheduler()
+8
View File
@@ -21,5 +21,13 @@ assert.equal(
permissionForRequest("GET", "/api/unknown-thing"), permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read", "mm:dashboard:read",
) )
assert.equal(
permissionForRequest("GET", "/api/wireguard"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write",
)
console.log("permissions.test.ts: ok") console.log("permissions.test.ts: ok")
+4 -2
View File
@@ -141,7 +141,8 @@ const RULES: Rule[] = [
p.startsWith("/api/recursive") || p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") || p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"), p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
permission: "mm:network:read", permission: "mm:network:read",
}, },
{ {
@@ -152,7 +153,8 @@ const RULES: Rule[] = [
p.startsWith("/api/recursive") || p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") || p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") || p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"), p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
permission: "mm:network:write", permission: "mm:network:write",
}, },
] ]
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
} }
} }
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw { interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> } modules: { items: Array<{ id: string; name: string; type: string }> }
domains: { domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null { function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl) const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim() const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null if (!root || !apiKey) return null
return { root, apiKey } return { root, apiKey }
} }
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled let nextEnabled = cur.enabled
let nextKey = cur.apiKey let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim() if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) { if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim() nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
} }
db.update(evobgpSettings) db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow() const row = ensureEvobgpRow()
return reply.send({ return reply.send({
baseUrl: row.baseUrl ?? "", baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false, enabled: Boolean(row.enabled),
secretConfigured: Boolean(row.apiKey?.trim()), secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
}) })
}) })
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw = const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim()) const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim() const token = normalizeApiKey(keyRaw)
if (!root || !token) { if (!root || !token) {
return reply.status(400).send({ return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)", error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+4
View File
@@ -1,5 +1,6 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listCertificatesFromServers } from "../services/certificates-service.js" import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { import {
filterRules, filterRules,
@@ -18,6 +19,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeSpeedProbesTotal, uptimeSpeedProbesTotal,
recursiveRoutesTotal, recursiveRoutesTotal,
certificatesTotal, certificatesTotal,
wireguardTotal,
] = await Promise.all([ ] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length), Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length), Promise.resolve(db.select().from(filterRules).all().length),
@@ -25,6 +27,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length), Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length), Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length), listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0),
]) ])
return reply.send({ return reply.send({
@@ -35,6 +38,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal, monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal, recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal, certificates: certificatesTotal,
wireguard: wireguardTotal,
}) })
}) })
} }
+456
View File
@@ -0,0 +1,456 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
wgCreateInterfaceSchema,
wgCreatePeerRequestSchema,
wgExportRequestSchema,
wgImportRequestSchema,
wgPatchInterfaceSchema,
wgPatchPeerSchema,
type WgCreatePeerRequest,
type WgIfaceDto,
} from "@mmapp/contracts/wireguard"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
parseWgConfig,
type WgParsedConfig,
} from "../services/wireguard-config.js"
import {
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
}
function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
"public-key": p.publicKey,
"allowed-address": p.allowedAddresses.join(","),
"endpoint-address": p.endpointAddress,
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
"persistent-keepalive":
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
comment: p.comment,
name: p.name,
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
"client-address": p.clientAddress,
"client-dns": p.clientDns,
"client-endpoint": p.clientEndpoint,
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
})
}
function previewFromParsed(parsed: WgParsedConfig) {
return {
format: parsed.format,
interface: {
name: parsed.interface.name,
listenPort: parsed.interface.listenPort,
mtu: parsed.interface.mtu,
privateKey: parsed.interface.privateKey,
comment: parsed.interface.comment,
address: parsed.interface.address,
disabled: parsed.interface.disabled,
},
peers: parsed.peers.map((p) => ({
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses,
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
})),
}
}
async function applyParsedConfig(
client: MikrotikClient,
parsed: WgParsedConfig,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const ifaceBody = toRosBody({
name,
"listen-port": String(parsed.interface.listenPort ?? 13231),
mtu: String(parsed.interface.mtu ?? 1420),
"private-key": parsed.interface.privateKey,
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
}),
)
peersCreated += 1
}
return { interfaceName: name, peersCreated }
}
function findIface(
list: WgIfaceDto[],
serverId: string,
interfaceName: string,
): WgIfaceDto | undefined {
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
}
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string }
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
const result = await listWireGuardInterfaces({
serverId: q.serverId,
includePrivateKey,
})
return reply.send(result)
})
app.post("/wireguard/interfaces", async (req, reply) => {
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
if (body.address) {
await client.put("/ip/address", {
address: body.address,
interface: body.name,
})
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
}
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: true,
})
const created = list.interfaces.find((i) => i.name === body.name)
return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const d = parsed.data
try {
await client.patch(
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
name: d.name,
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
mtu: d.mtu != null ? String(d.mtu) : undefined,
comment: d.comment,
"private-key": d.privateKey,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/peers", async (req, reply) => {
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const d = parsed.data
const client = MikrotikClient.fromServer(server)
try {
await client.patch(
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
"public-key": d.publicKey,
"allowed-address": d.allowedAddresses?.join(","),
"endpoint-address": d.endpointAddress,
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
"persistent-keepalive":
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
comment: d.comment,
name: d.name,
"client-address": d.clientAddress,
"client-dns": d.clientDns,
"client-endpoint": d.clientEndpoint,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/import", async (req, reply) => {
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
let config: WgParsedConfig
try {
config = parseWgConfig(body.content, body.format)
} catch (e) {
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
}
const preview = previewFromParsed(config)
if (body.dryRun) {
return reply.send({ dryRun: true, preview })
}
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const applied = await applyParsedConfig(client, config)
return reply.send({ dryRun: false, preview, applied })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
}
})
app.post("/wireguard/export", async (req, reply) => {
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: body.includePrivateKey === true,
})
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
if (body.format === "rsc") {
const content = generateMikrotikRsc({
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
})
return reply.send({
format: "rsc",
filename: `${iface.name}.rsc`,
content,
})
}
if (body.format === "conf") {
const content = generateNativeConf(
{
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: iface.privateKey,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
})),
},
{ includePrivateKey: body.includePrivateKey === true },
)
return reply.send({
format: "conf",
filename: `${iface.name}.conf`,
content,
})
}
// peer-conf
const peer = body.peerId
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
: iface.peers[0]
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
if (!iface.publicKey) {
return reply.status(400).send({ error: "У интерфейса нет public-key" })
}
const endpoint =
peer.clientEndpoint ||
(peer.endpoint
? peer.endpoint
: undefined)
const content = generatePeerClientConf({
peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
endpoint:
endpoint ||
(peer.clientEndpoint
? peer.clientEndpoint.includes(":")
? peer.clientEndpoint
: `${peer.clientEndpoint}:${iface.listenPort}`
: undefined),
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
return reply.send({
format: "peer-conf",
filename: `${iface.name}-peer.conf`,
content,
})
})
}
export default wireguardRoutes
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
) )
} }
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> { async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn) const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A") const existingA = records.find((record) => record.type === "A")
if (existingA) { if (existingA) {
if (existingA.content === ip) return if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, { await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return return "updated"
} }
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) { if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`) return "skipped_cname"
} }
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, { await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false, proxied: false,
}), }),
}) })
return "created"
} }
async function syncCertificateDomainRecords( async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[], domains: string[],
serverIp: string, serverIp: string,
defaultZoneId?: string, defaultZoneId?: string,
): Promise<void> { ): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) { for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId) const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp) const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
} }
return { skippedCname }
} }
async function sleep(ms: number) { async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr) const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized) const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server) const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos) try {
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId) params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean) const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"] const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
@@ -0,0 +1,100 @@
import assert from "node:assert/strict"
import {
detectWgConfigFormat,
generateMikrotikRsc,
generateNativeConf,
parseMikrotikRsc,
parseNativeConf,
parseWgConfig,
} from "./wireguard-config.js"
const sampleConf = `[Interface]
PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
Address = 10.210.0.1/30
ListenPort = 13231
MTU = 1420
[Peer]
PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
AllowedIPs = 10.210.0.2/32, 192.168.20.0/24
Endpoint = 10.0.1.1:13231
PersistentKeepalive = 25
`
const parsedConf = parseNativeConf(sampleConf)
assert.equal(parsedConf.format, "conf")
assert.equal(parsedConf.interface.listenPort, 13231)
assert.equal(parsedConf.interface.address, "10.210.0.1/30")
assert.equal(parsedConf.peers.length, 1)
assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1")
assert.equal(parsedConf.peers[0]?.endpointPort, 13231)
assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"])
const roundConf = generateNativeConf({
name: "wg0",
listenPort: parsedConf.interface.listenPort ?? 13231,
mtu: parsedConf.interface.mtu ?? 1420,
privateKey: parsedConf.interface.privateKey,
address: parsedConf.interface.address,
peers: parsedConf.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const reparsed = parseNativeConf(roundConf)
assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey)
assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey)
const sampleRsc = `# WireGuard
/interface wireguard add \\
name=wg-msk-spb \\
listen-port=13231 \\
mtu=1420 \\
comment="MSK → SPB"
/ip address add \\
address=10.210.0.1/30 \\
interface=wg-msk-spb
/interface wireguard peers add \\
interface=wg-msk-spb \\
public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\
allowed-address=10.210.0.2/32,192.168.20.0/24 \\
endpoint-address=10.0.1.1 \\
endpoint-port=13231 \\
persistent-keepalive=25
`
assert.equal(detectWgConfigFormat(sampleRsc), "rsc")
assert.equal(detectWgConfigFormat(sampleConf), "conf")
const parsedRsc = parseMikrotikRsc(sampleRsc)
assert.equal(parsedRsc.interface.name, "wg-msk-spb")
assert.equal(parsedRsc.interface.address, "10.210.0.1/30")
assert.equal(parsedRsc.peers.length, 1)
assert.equal(parsedRsc.peers[0]?.endpointPort, 13231)
const generatedRsc = generateMikrotikRsc({
name: parsedRsc.interface.name,
listenPort: parsedRsc.interface.listenPort ?? 13231,
mtu: parsedRsc.interface.mtu ?? 1420,
comment: parsedRsc.interface.comment,
address: parsedRsc.interface.address,
peers: parsedRsc.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const rscAgain = parseWgConfig(generatedRsc, "rsc")
assert.equal(rscAgain.interface.name, "wg-msk-spb")
assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey)
console.log("wireguard-config tests ok")
+359
View File
@@ -0,0 +1,359 @@
/**
* WireGuard config codecs: native .conf MikroTik .rsc
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: "auto" | "none" | string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
disabled?: boolean
}
export type WgParsedInterface = {
name: string
listenPort?: number
mtu?: number
privateKey?: string
comment?: string
address?: string
disabled?: boolean
}
export type WgParsedConfig = {
format: "rsc" | "conf"
interface: WgParsedInterface
peers: WgParsedPeer[]
}
export type WgExportIface = {
name: string
listenPort: number
mtu: number
comment?: string
enabled?: boolean
privateKey?: string
publicKey?: string
address?: string
serverName?: string
peers: Array<{
publicKey: string
allowedIps: string[]
endpoint?: string
persistentKeepalive?: number
persistent?: boolean
comment?: string
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}>
}
function stripQuotes(v: string): string {
const t = v.trim()
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
function parseKvLine(line: string): Record<string, string> {
const out: Record<string, string> = {}
// Match key=value pairs; values may be quoted
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
out[m[1]] = stripQuotes(m[2])
}
return out
}
function joinContinuedLines(text: string): string[] {
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
const lines: string[] = []
let buf = ""
for (const line of raw) {
const trimmedEnd = line.replace(/\s+$/, "")
if (trimmedEnd.endsWith("\\")) {
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
continue
}
buf += trimmedEnd
if (buf.trim()) lines.push(buf.trim())
buf = ""
}
if (buf.trim()) lines.push(buf.trim())
return lines
}
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
const t = content.trim()
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
return "rsc"
}
export function parseNativeConf(content: string): WgParsedConfig {
const lines = content.replace(/\r\n/g, "\n").split("\n")
let section: "interface" | "peer" | null = null
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let currentPeer: WgParsedPeer | null = null
const flushPeer = () => {
if (currentPeer?.publicKey) peers.push(currentPeer)
currentPeer = null
}
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith("#") || line.startsWith(";")) continue
if (/^\[Interface\]$/i.test(line)) {
flushPeer()
section = "interface"
continue
}
if (/^\[Peer\]$/i.test(line)) {
flushPeer()
section = "peer"
currentPeer = { publicKey: "", allowedAddresses: [] }
continue
}
const eq = line.indexOf("=")
if (eq < 0) continue
const key = line.slice(0, eq).trim().toLowerCase()
const value = line.slice(eq + 1).trim()
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
else if (key === "allowedips") {
currentPeer.allowedAddresses = value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
} else if (key === "endpoint") {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
} else if (value.startsWith("[") && value.includes("]:")) {
const idx = value.indexOf("]:")
currentPeer.endpointAddress = value.slice(1, idx)
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
} else if (key === "presharedkey") {
// ignore PSK for ROS import for now
}
}
}
flushPeer()
if (!iface.name) iface.name = "wg0"
return { format: "conf", interface: iface, peers }
}
export function parseMikrotikRsc(content: string): WgParsedConfig {
const lines = joinContinuedLines(content)
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let foundIface = false
for (const line of lines) {
if (line.startsWith("#")) continue
const lower = line.toLowerCase()
if (
lower.startsWith("/interface wireguard add") ||
lower.startsWith("/interface/wireguard add")
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
foundIface = true
continue
}
if (
lower.startsWith("/interface wireguard peers add") ||
lower.startsWith("/interface/wireguard/peers add")
) {
const kv = parseKvLine(line)
const allowed = (kv["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
: undefined,
comment: kv.comment,
name: kv.name,
clientAddress: kv["client-address"],
clientDns: kv["client-dns"],
clientEndpoint: kv["client-endpoint"],
disabled: kv.disabled === "yes",
})
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
const kv = parseKvLine(line)
if (kv.address) iface.address = kv.address
continue
}
}
if (!foundIface && peers.length === 0) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
}
export function parseWgConfig(
content: string,
format: "auto" | "rsc" | "conf" = "auto",
): WgParsedConfig {
const detected = format === "auto" ? detectWgConfigFormat(content) : format
if (detected === "conf") return parseNativeConf(content)
return parseMikrotikRsc(content)
}
export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string {
const lines: string[] = []
lines.push(`[Interface]`)
if (opts?.includePrivateKey && iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else if (iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else {
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
}
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`[Peer]`)
lines.push(`PublicKey = ${p.publicKey}`)
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
if (p.comment) lines.push(`# ${p.comment}`)
lines.push(``)
}
return lines.join("\n").trimEnd() + "\n"
}
export function generatePeerClientConf(args: {
peerPrivateKey?: string
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(
args.peerPrivateKey
? `PrivateKey = ${args.peerPrivateKey}`
: `# PrivateKey = <ключ клиента>`,
)
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
lines.push(``)
lines.push(`[Peer]`)
lines.push(`PublicKey = ${args.serverPublicKey}`)
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
}
lines.push(``)
return lines.join("\n")
}
export function generateMikrotikRsc(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
lines.push(`# RouterOS 7.x · MikrotikManager`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
if (iface.enabled === false) lines.push(` disabled=yes \\`)
// remove trailing backslash on last iface param by rewriting last line
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(` address=${iface.address} \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) {
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
const port = p.endpoint.includes(":")
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
: "13231"
lines.push(` endpoint-address=${host} \\`)
lines.push(` endpoint-port=${port} \\`)
}
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
if (p.name) lines.push(` name=${p.name} \\`)
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
}
return lines.join("\n")
}
+214
View File
@@ -0,0 +1,214 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
type ServerRow = typeof servers.$inferSelect
interface RosWireGuard {
".id"?: string
name?: string
"listen-port"?: string
mtu?: string
"public-key"?: string
"private-key"?: string
running?: string
disabled?: string
comment?: string
}
interface RosWireGuardPeer {
".id"?: string
interface?: string
name?: string
"public-key"?: string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address"?: string
"last-handshake"?: string
rx?: string
tx?: string
disabled?: string
comment?: string
"persistent-keepalive"?: string
"client-address"?: string
"client-dns"?: string
"client-endpoint"?: string
}
interface RosIpAddress {
".id"?: string
address?: string
interface?: string
disabled?: string
}
function parseBytes(v: string | undefined): number | undefined {
if (v == null || v === "") return undefined
const n = Number.parseInt(v, 10)
return Number.isFinite(n) ? n : undefined
}
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
const rosId = String(p[".id"] ?? `peer-${idx}`)
const allowed = (p["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const epAddr = (p["endpoint-address"] ?? "").trim()
const epPort = (p["endpoint-port"] ?? "").trim()
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
const ka = p["persistent-keepalive"]
? Number.parseInt(p["persistent-keepalive"], 10)
: undefined
return {
id: rosId,
rosId,
publicKey: p["public-key"] ?? "",
allowedIps: allowed,
endpoint,
latestHandshake: p["last-handshake"]?.trim() || undefined,
transferRx: parseBytes(p.rx),
transferTx: parseBytes(p.tx),
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
persistent: Number.isFinite(ka) && (ka as number) > 0,
comment: p.comment ?? undefined,
disabled: p.disabled === "true" || p.disabled === "yes",
name: p.name,
clientAddress: p["client-address"],
clientDns: p["client-dns"],
clientEndpoint: p["client-endpoint"],
}
}
function mapIface(
server: ServerRow,
w: RosWireGuard,
peers: WgPeerDto[],
address: string | undefined,
includePrivateKey: boolean,
): WgIfaceDto {
const rosId = String(w[".id"] ?? w.name ?? "wg")
const name = (w.name ?? "").trim() || rosId
const disabled = w.disabled === "true" || w.disabled === "yes"
const running = w.running === "true" || w.running === "yes"
return {
id: `${server.id}:${rosId}`,
rosId,
name,
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
publicKey: w["public-key"] || undefined,
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
address,
peers,
comment: w.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "down",
}
}
async function fetchForServer(
server: ServerRow,
includePrivateKey: boolean,
): Promise<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const peersByIface = new Map<string, WgPeerDto[]>()
peersRaw.forEach((p, idx) => {
const ifaceName = (p.interface ?? "").trim()
if (!ifaceName) return
const list = peersByIface.get(ifaceName) ?? []
list.push(mapPeer(p, idx))
peersByIface.set(ifaceName, list)
})
const addrByIface = new Map<string, string>()
for (const a of addrsRaw) {
if (a.disabled === "true" || a.disabled === "yes") continue
const iface = (a.interface ?? "").trim()
const addr = (a.address ?? "").trim()
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
}
return ifacesRaw.map((w) => {
const name = (w.name ?? "").trim()
return mapIface(
server,
w,
peersByIface.get(name) ?? [],
addrByIface.get(name),
includePrivateKey,
)
})
}
export type WgListResult = {
interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}
export async function listWireGuardInterfaces(opts?: {
serverId?: string
includePrivateKey?: boolean
}): Promise<WgListResult> {
const includePrivateKey = opts?.includePrivateKey === true
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
serverRows = row ? [row] : []
} else {
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
}
const failures: WgListResult["failures"] = []
const results = await Promise.all(
serverRows.map(async (server) => {
try {
return await fetchForServer(server, includePrivateKey)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
return [] as WgIfaceDto[]
}
}),
)
return { interfaces: results.flat(), failures }
}
export async function countWireGuardInterfaces(): Promise<number> {
try {
const result = await Promise.race([
listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.interfaces.length
} catch {
return 0
}
}
export function getEnabledServerById(serverId: string | number): ServerRow | null {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
}
export { type RosWireGuard, type RosWireGuardPeer }
+14 -21
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context" import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import { import {
formatSidebarBadgeCount, formatSidebarBadgeCount,
mockSidebarBadgesByUrl, mockSidebarBadgesByUrl,
@@ -101,10 +102,10 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
}, },
] ]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number } type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP() const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false) const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null) const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
if (mode !== "live") { if (!prefsHydrated || mode !== "live") {
setLiveCounts(null) if (mode !== "live") setLiveCounts(null)
return return
} }
let cancelled = false let cancelled = false
const load = async () => { const load = async () => {
try { try {
const base = backendUrl.replace(/\/$/, "") const [cJson, gJson] = await Promise.all([
const [cRes, gRes] = await Promise.all([ requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
fetch(`${base}/api/sidebar-counts`), requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
fetch(`${base}/api/filters/gre-tunnels`), () => ({ tunnels: [] as unknown[] }),
),
]) ])
if (cancelled) return if (cancelled) return
if (!cRes.ok) { setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
} catch { } catch {
if (!cancelled) setLiveCounts(null) if (!cancelled) setLiveCounts(null)
} }
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true cancelled = true
window.clearInterval(id) window.clearInterval(id)
} }
}, [mode, backendUrl]) }, [mode, backendUrl, prefsHydrated])
const navGroups = React.useMemo((): NavGroup[] => { const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined { function badgeFor(url: string): string | undefined {
@@ -173,8 +165,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems) if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0) if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0) if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/bgp") { if (url === "/containers" || url === "/bgp") {
return undefined return undefined
} }
@@ -8,7 +8,7 @@ import {
} from "@tanstack/react-table" } from "@tanstack/react-table"
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings" import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
import { FormToggle } from "@/components/form-kit" import { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
+3 -3
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
CompactDataGrid, CompactDataGrid,
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
errLabel?: string errLabel?: string
}) { }) {
return ok ? ( return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400"> <Badge variant="success-outline" size="sm" className="text-[10px]">
{okLabel} {okLabel}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive"> <Badge variant="destructive-outline" size="sm" className="text-[10px]">
{errLabel} {errLabel}
</Badge> </Badge>
) )
+54 -34
View File
@@ -11,6 +11,7 @@ import {
import type { WireGuardInterface } from "@/lib/data" import type { WireGuardInterface } from "@/lib/data"
import { Flag } from "@/components/flag" import { Flag } from "@/components/flag"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -33,7 +34,6 @@ import {
ChevronRightIcon, ChevronRightIcon,
CodeXmlIcon, CodeXmlIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
PencilIcon,
PlusIcon, PlusIcon,
PowerIcon, PowerIcon,
ShieldCheckIcon, ShieldCheckIcon,
@@ -49,9 +49,22 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps { interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[] interfaces: WgIfaceWithServer[]
onExport: (iface: WgIfaceWithServer) => void onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void
onDelete?: (iface: WgIfaceWithServer) => void
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
} }
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) { function WireguardDataGrid({
interfaces,
onExport,
onAddPeer,
onToggle,
onDelete,
onDeletePeer,
onExportPeer,
}: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>( const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
() => [ () => [
{ {
@@ -76,13 +89,13 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<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>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono"> <div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} /> <Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName} {iface.serverName}
</div> </div>
<p className="sr-only"> <p className="sr-only">
@@ -97,7 +110,11 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
headerClassName: DATA_GRID_CELL_PAD_FIRST, headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => ( expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} /> <WireGuardPeersDetail
peers={row.peers}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
), ),
}, },
}, },
@@ -136,7 +153,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
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>
) )
@@ -152,16 +169,13 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
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: "Статус",
@@ -203,26 +217,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<DropdownMenuContent side="bottom" align="end"> <DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}> <DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" /> <CodeXmlIcon className="size-4" />
Экспорт .rsc Экспорт
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem> </DropdownMenuItem>
{onAddPeer && (
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
)}
{onToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(iface)}>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -236,7 +256,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
}, },
}, },
], ],
[onExport], [onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
) )
const table = useReactTable({ const table = useReactTable({
@@ -2,10 +2,13 @@
import type { WireGuardPeer } from "@/lib/data" import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon, KeyRoundIcon,
Trash2Icon,
} from "lucide-react" } from "lucide-react"
function fmtBytes(n: number | undefined): string { function fmtBytes(n: number | undefined): string {
@@ -21,7 +24,19 @@ function truncKey(key: string): string {
return `${key.slice(0, 8)}${key.slice(-8)}` return `${key.slice(0, 8)}${key.slice(-8)}`
} }
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) { function peerKey(peer: WireGuardPeer, index: number): string {
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
}
function WireGuardPeersDetail({
peers,
onDeletePeer,
onExportPeer,
}: {
peers: WireGuardPeer[]
onDeletePeer?: (peerId: string) => void
onExportPeer?: (peerId: string) => void
}) {
if (peers.length === 0) { if (peers.length === 0) {
return ( return (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50"> <div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
return ( return (
<div className="border-t border-border/50"> <div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span> <span>Public Key</span>
<span>Allowed IPs</span> <span>Allowed IPs</span>
<span>Последнее рукопожатие</span> <span>Последнее рукопожатие</span>
<span>RX / TX</span> <span>RX / TX</span>
<span>Endpoint</span> <span>Endpoint</span>
<span className="sr-only">Действия</span>
</div> </div>
{peers.map((peer) => ( {peers.map((peer, index) => {
<div const id = peerKey(peer, index)
key={peer.publicKey} return (
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20" <div
> key={id}
<div className="flex items-center gap-1.5 min-w-0"> className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
> >
{peer.latestHandshake ?? "нет рукопожатия"} <div className="flex items-center gap-1.5 min-w-0">
</span> <KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap"> <span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
<span className="flex items-center gap-0.5"> {truncKey(peer.publicKey)}
<ArrowDownIcon className="size-3 text-emerald-500" /> </span>
{fmtBytes(peer.transferRx)} </div>
</span> <div className="font-mono text-muted-foreground truncate">
<span className="flex items-center gap-0.5"> {peer.allowedIps.join(", ")}
<ArrowUpIcon className="size-3 text-blue-400" /> </div>
{fmtBytes(peer.transferTx)} <span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span> </span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
<div className="flex items-center gap-1 justify-end">
{onExportPeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={(e) => {
e.stopPropagation()
onExportPeer(id)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
)}
{onDeletePeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={(e) => {
e.stopPropagation()
onDeletePeer(id)
}}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</div> </div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span> )
</div> })}
))}
</div> </div>
) )
} }
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
+246
View File
@@ -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 }
+5
View File
@@ -0,0 +1,5 @@
export { CodeExportSheet, downloadText } from "./code-export-sheet"
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
export { kpiCols } from "./kpi-cols"
+12
View File
@@ -0,0 +1,12 @@
/**
* Shared grid column classes for hybrid KPI tiles.
* @see https://reui.io/preview/base/stats-12
*/
export function kpiCols(count: number): string {
if (count <= 1) return "grid-cols-1"
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
}
+307
View File
@@ -0,0 +1,307 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import Link from "next/link"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import { kpiCols } from "./kpi-cols"
export type KpiStatVariant = "default" | "warning" | "destructive"
/**
* KPI tile horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
href?: string
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
export type KpiStatCardData = KpiStatItem & { id: string }
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: "text-foreground",
warning: "text-warning",
destructive: "text-destructive",
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === "string") {
return (
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? "default"
return (
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.href || onActivate)
const selected = isSelected(item)
return cn(
"relative isolate flex h-full min-w-0 flex-col",
clickable &&
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
selected && "ring-primary/30 bg-muted/30 ring-1",
className,
)
}
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.href) {
panel = (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn("grid gap-2", kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
items?: KpiStatItem[]
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
embedded?: boolean
"aria-label"?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.href) {
return (
<FramePanel className={panelClass}>
<Link href={item.href} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
"aria-label": ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn("w-full", className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === "string") return item.label
return `kpi-${index}`
}
+8 -11
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data" import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges" import type { SidebarCountsDto } from "@/lib/sidebar-badges"
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
type MonitorMetric = { type MonitorMetric = {
id: string id: string
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */ /** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() { export function SystemMonitorPopover() {
const { mode, backendUrl } = useDataSource() const { mode, backendUrl, prefsHydrated } = useDataSource()
const [healthOk, setHealthOk] = useState<boolean | null>(null) const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [counts, setCounts] = useState<SidebarCountsDto | null>(null) const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
useEffect(() => { useEffect(() => {
if (!prefsHydrated) return
if (mode !== "live") { if (mode !== "live") {
setHealthOk(true) setHealthOk(true)
setCounts({ setCounts({
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
let cancelled = false let cancelled = false
const load = async () => { const load = async () => {
const base = backendUrl.replace(/\/$/, "")
try { try {
const [hRes, cRes] = await Promise.all([ const [hRes, counts] = await Promise.all([
fetch(`${base}/health`), fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
fetch(`${base}/api/sidebar-counts`), requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
]) ])
if (cancelled) return if (cancelled) return
if (hRes.ok) { if (hRes.ok) {
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
} else { } else {
setHealthOk(false) setHealthOk(false)
} }
if (cRes.ok) { setCounts(counts)
setCounts((await cRes.json()) as SidebarCountsDto)
} else {
setCounts(null)
}
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setHealthOk(false) setHealthOk(false)
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
cancelled = true cancelled = true
window.clearInterval(id) window.clearInterval(id)
} }
}, [mode, backendUrl]) }, [mode, backendUrl, prefsHydrated])
const serversCount = counts?.servers ?? 0 const serversCount = counts?.servers ?? 0
const filtersCount = counts?.filterRules ?? 0 const filtersCount = counts?.filterRules ?? 0
+232
View File
@@ -0,0 +1,232 @@
"use client"
import { useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
export type WgCreateFormState = {
serverId: string
name: string
listenPort: string
mtu: string
comment: string
address: string
enabled: boolean
showAdvanced: boolean
peerEnabled: boolean
peerPublicKey: string
peerAllowedIps: string
peerEndpoint: string
peerKeepalive: string
peerComment: string
}
export const defaultWgCreateForm = (): WgCreateFormState => ({
serverId: "",
name: "",
listenPort: "13231",
mtu: "1420",
comment: "",
address: "",
enabled: true,
showAdvanced: false,
peerEnabled: false,
peerPublicKey: "",
peerAllowedIps: "",
peerEndpoint: "",
peerKeepalive: "25",
peerComment: "",
})
type ServerOption = { id: string; name: string; host: string }
function WgCreateSheet({
open,
onOpenChange,
servers,
busy,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort])
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
<SheetDescription>
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.serverId}
onChange={(e) => set("serverId", e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Имя интерфейса" required hint="Например wg-msk-spb">
<Input
className="font-mono"
placeholder="wg0"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Listen port" required>
<Input
className="font-mono"
value={form.listenPort}
onChange={(e) => set("listenPort", e.target.value)}
/>
</FormField>
<FormField label="MTU">
<Input
className="font-mono"
value={form.mtu}
onChange={(e) => set("mtu", e.target.value)}
/>
</FormField>
</div>
<FormField label="Комментарий">
<Input
value={form.comment}
onChange={(e) => set("comment", e.target.value)}
placeholder="MSK → SPB overlay"
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Включён</p>
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
</div>
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => set("showAdvanced", !form.showAdvanced)}
>
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
Дополнительно
</button>
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<FormField label="IP на интерфейсе" hint="/ip address add, например 10.210.0.1/30">
<Input
className="font-mono"
placeholder="10.210.0.1/30"
value={form.address}
onChange={(e) => set("address", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Добавить первого пира</p>
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
</div>
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
</div>
{form.peerEnabled && (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<FormField label="Public key пира" required>
<Input
className="font-mono text-xs"
value={form.peerPublicKey}
onChange={(e) => set("peerPublicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.peerAllowedIps}
onChange={(e) => set("peerAllowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.peerEndpoint}
onChange={(e) => set("peerEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.peerKeepalive}
onChange={(e) => set("peerKeepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий пира">
<Input
value={form.peerComment}
onChange={(e) => set("peerComment", e.target.value)}
/>
</FormField>
</div>
)}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? "Создание…" : "Создать туннель"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgCreateSheet }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import { useMemo } from "react"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
import {
CodeExportSheet,
type CodeExportFormat,
} from "@/components/reui-kit/code-export-sheet"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
} from "@/lib/wg-config"
function WgExportSheet({
open,
iface,
onClose,
liveContent,
liveBusy,
onRequestLiveExport,
initialTab = "rsc",
peerId,
}: {
open: boolean
iface: WgIfaceWithServer | null
onClose: () => void
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
liveBusy?: boolean
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void
initialTab?: "rsc" | "conf" | "peer"
/** Selected peer for Peer .conf (defaults to first peer) */
peerId?: string | null
}) {
const formats = useMemo((): CodeExportFormat[] => {
if (!iface) {
return [
{ id: "rsc", label: "MikroTik .rsc", filename: "wg.rsc", code: "" },
{ id: "conf", label: "Native .conf", filename: "wg.conf", code: "" },
{
id: "peer",
label: "Peer .conf",
filename: "wg-peer.conf",
code: "",
emptyMessage: "Интерфейс не выбран",
},
]
}
const base = {
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: iface.privateKey,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
}
const peer =
(peerId
? iface.peers.find((p) => p.id === peerId || p.rosId === peerId)
: undefined) ?? iface.peers[0]
const localRsc = generateMikrotikRsc(base)
const localConf = generateNativeConf(base)
let peerConf = ""
let peerEmpty: string | undefined
if (!iface.publicKey) {
peerEmpty = "Нет public-key интерфейса для клиентского .conf"
} else if (!peer) {
peerEmpty = "Нет пиров для экспорта Peer .conf"
} else {
peerConf = generatePeerClientConf({
peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps,
endpoint: peer.clientEndpoint || peer.endpoint || undefined,
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
}
return [
{
id: "rsc",
label: "MikroTik .rsc",
filename: `${iface.name}.rsc`,
code: liveContent?.rsc ?? localRsc,
},
{
id: "conf",
label: "Native .conf",
filename: `${iface.name}.conf`,
code: liveContent?.conf ?? localConf,
},
{
id: "peer",
label: "Peer .conf",
filename: `${iface.name}-peer.conf`,
code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
},
]
}, [iface, liveContent, peerId])
return (
<CodeExportSheet
open={open}
onClose={onClose}
title="Экспорт WireGuard"
description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
formats={formats}
initialFormatId={initialTab}
liveBusy={liveBusy}
onLiveFetch={
onRequestLiveExport
? (formatId) =>
onRequestLiveExport(
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
)
: undefined
}
/>
)
}
export { WgExportSheet }
+182
View File
@@ -0,0 +1,182 @@
"use client"
import { useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { detectWgConfigFormat, parseWgConfig, type WgParsedConfig } from "@/lib/wg-config"
import { UploadIcon } from "lucide-react"
type ServerOption = { id: string; name: string; host: string }
function WgImportSheet({
open,
onOpenChange,
servers,
busy,
onImport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
onImport: (args: {
serverId: string
content: string
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) => Promise<void>
}) {
const [serverId, setServerId] = useState("")
const [content, setContent] = useState("")
const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto")
const [preview, setPreview] = useState<WgParsedConfig | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const detected = useMemo(
() => (content.trim() ? detectWgConfigFormat(content) : null),
[content],
)
function runPreview() {
setParseError(null)
setPreview(null)
try {
setPreview(parseWgConfig(content, format))
} catch (e) {
setParseError(e instanceof Error ? e.message : "Ошибка разбора")
}
}
function onFile(file: File | null) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
setContent(String(reader.result ?? ""))
setPreview(null)
setParseError(null)
}
reader.readAsText(file)
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (!v) {
setContent("")
setPreview(null)
setParseError(null)
setServerId("")
}
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
<SheetDescription>
Native .conf или MikroTik .rsc применить на выбранный роутер
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Формат">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={format}
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
>
<option value="auto">Авто{detected ? ` (${detected})` : ""}</option>
<option value="conf">Native WireGuard (.conf)</option>
<option value="rsc">MikroTik (.rsc)</option>
</select>
</FormField>
<div className="flex flex-col gap-2">
<SectionTitle>Содержимое</SectionTitle>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer w-fit">
<UploadIcon className="size-4" />
Загрузить файл
<input
type="file"
accept=".conf,.rsc,.txt,text/plain"
className="sr-only"
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
</label>
<textarea
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
value={content}
onChange={(e) => {
setContent(e.target.value)
setPreview(null)
setParseError(null)
}}
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={runPreview} disabled={!content.trim()}>
Предпросмотр
</Button>
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
{preview && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
<p className="font-medium">
{preview.format.toUpperCase()} · {preview.interface.name}
</p>
<p className="text-xs text-muted-foreground font-mono">
port={preview.interface.listenPort ?? "—"} · mtu={preview.interface.mtu ?? "—"}
{preview.interface.address ? ` · ${preview.interface.address}` : ""}
</p>
<p className="text-xs text-muted-foreground">Пиров: {preview.peers.length}</p>
{preview.peers.slice(0, 5).map((p, i) => (
<p key={i} className="text-[11px] font-mono text-muted-foreground truncate">
{p.publicKey.slice(0, 16)} {p.allowedAddresses.join(", ")}
</p>
))}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
<div className="flex w-full gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!serverId || !content.trim() || busy}
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
>
{busy ? "Импорт…" : "Применить на роутер"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgImportSheet }
+114
View File
@@ -0,0 +1,114 @@
"use client"
import { useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
export type WgPeerFormState = {
publicKey: string
allowedIps: string
endpoint: string
keepalive: string
comment: string
}
const emptyPeerForm = (): WgPeerFormState => ({
publicKey: "",
allowedIps: "",
endpoint: "",
keepalive: "25",
comment: "",
})
function WgPeerSheet({
open,
iface,
busy,
onOpenChange,
onSubmit,
}: {
open: boolean
iface: WgIfaceWithServer | null
busy?: boolean
onOpenChange: (v: boolean) => void
onSubmit: (form: WgPeerFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgPeerFormState>(emptyPeerForm)
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(emptyPeerForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Добавить пира</SheetTitle>
<SheetDescription>
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<SectionTitle>Параметры пира</SectionTitle>
<FormField label="Public key" required>
<Input
className="font-mono text-xs"
value={form.publicKey}
onChange={(e) => set("publicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.allowedIps}
onChange={(e) => set("allowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.endpoint}
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.keepalive}
onChange={(e) => set("keepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий">
<Input value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
onClick={() => void onSubmit(form)}
>
{busy ? "Сохранение…" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgPeerSheet }
+79
View File
@@ -0,0 +1,79 @@
# UI Design Contract (MikrotikManager)
Surface: **ReUI Frame**. Kit: `components/reui-kit/`.
Иерархия: **ReUI PRO > shadcn primitives**.
Стек: Next.js 16 App Router + Turbopack + shadcn **base-nova** + ReUI `@reui`.
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
## Surface
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как page shell. Не смешивать Card и Frame на одном ops-экране.
Эталон CRUD: [`app/(main)/servers`](../app/(main)/servers).
Оболочка списков: `DataPageCard` → Frame. Панели: `OpsPanel` → Frame.
## Canonical PRO references
| Зона | Preview |
|------|---------|
| Shell | https://reui.io/preview/base/app-shell-12 |
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Kit (`components/reui-kit/`)
| Component | Role |
|-----------|------|
| `KpiStatGrid` | Hybrid KPI (IconTile elevated `size-10.5`) |
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
Слои:
| Слой | Путь |
|------|------|
| shadcn | `components/ui/` |
| ReUI CLI | `components/reui/` |
| Kit | `components/reui-kit/` |
| Domain grids | `components/data-grids/` |
## Shared App Shell chrome
| Токен / зона | Значение |
|--------------|----------|
| `--sidebar-width` | `240px` |
| Header right | AppsMenu → SystemMonitorPopover (тема — в NavUser) |
| Search | ⌘K / Ctrl+K only |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
## Spacing & tokens
- `flex` + `gap-*` — не `space-y-*` / `space-x-*`
- Semantic tokens / ReUI Badge variants — не raw `bg-emerald-*` / hex
- Max 1 primary CTA на экран
## Exceptions (не Frame ops shell)
- `network-map` — canvas / topology UX
- `terminal` — terminal chrome
## Forbidden
- Card как ops list/dashboard shell
- Hand-roll data-grid / KPI / code-export при наличии kit
- Дубль Copy в header + footer export sheet
- Mixing Card и Frame на одном ops-экране
- Radix-варианты docs — только Base UI (`base-nova`)
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`components.json``@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
+15 -1
View File
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
return cfg.kind === "same-origin" || cfg.kind === "fixed" return cfg.kind === "same-origin" || cfg.kind === "fixed"
} }
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1"
}
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
export function resolveStoredBackendUrl(stored: string | null): string { export function resolveStoredBackendUrl(stored: string | null): string {
const cfg = configuredBackendUrl() const cfg = configuredBackendUrl()
if (cfg.kind === "fixed") return cfg.url if (cfg.kind === "fixed") return cfg.url
if (cfg.kind === "same-origin" && typeof window !== "undefined") { if (cfg.kind === "same-origin") {
if (typeof window !== "undefined") return window.location.origin
return ""
}
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin return window.location.origin
} }
const trimmed = stored?.trim().replace(/\/$/, "") const trimmed = stored?.trim().replace(/\/$/, "")
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
}
return trimmed || LOCAL_DEFAULT_BACKEND_URL return trimmed || LOCAL_DEFAULT_BACKEND_URL
} }
+11 -11
View File
@@ -9,6 +9,7 @@ import {
LOCAL_DEFAULT_BACKEND_URL, LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl, resolveStoredBackendUrl,
} from "@/lib/backend-url" } from "@/lib/backend-url"
import { resolveApiUrl } from "@/shared/api/http-client"
// ── types ───────────────────────────────────────────────────────────────────── // ── types ─────────────────────────────────────────────────────────────────────
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
return defaultDataSourceMode() return defaultDataSourceMode()
} }
function readStoredBackendUrl(): string { function initialBackendUrl(): string {
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL if (typeof window === "undefined") {
const cfg = configuredBackendUrl()
if (cfg.kind === "same-origin") return ""
if (cfg.kind === "fixed") return cfg.url
return LOCAL_DEFAULT_BACKEND_URL
}
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
} }
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
export function DataSourceProvider({ children }: { children: React.ReactNode }) { export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode) const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
const [prefsHydrated, setPrefsHydrated] = useState(false) const [prefsHydrated, setPrefsHydrated] = useState(false)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined) const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked() const backendUrlLocked = isBackendUrlLocked()
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
useEffect(() => { useEffect(() => {
const storedMode = readStoredMode() const storedMode = readStoredMode()
let url = readStoredBackendUrl() const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
if (configuredBackendUrl().kind === "same-origin") {
url = window.location.origin
}
setModeState(storedMode) setModeState(storedMode)
setBackendUrlState(url) setBackendUrlState(url)
setPrefsHydrated(true) setPrefsHydrated(true)
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked]) }, [backendUrlLocked])
const checkBackend = useCallback(async () => { const checkBackend = useCallback(async () => {
const healthUrl = const healthUrl = resolveApiUrl(backendUrl, "/health")
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
try { try {
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) }) const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok) setBackendStatus(res.ok)
+12
View File
@@ -14,21 +14,33 @@ export interface WanUplink {
// ─── WireGuard ─────────────────────────────────────────────────────────────── // ─── WireGuard ───────────────────────────────────────────────────────────────
export interface WireGuardPeer { export interface WireGuardPeer {
id?: string
rosId?: string
publicKey: string publicKey: string
allowedIps: string[] allowedIps: string[]
endpoint?: string // "1.2.3.4:13231" endpoint?: string // "1.2.3.4:13231"
latestHandshake?: string // "2 минуты назад" latestHandshake?: string // "2 минуты назад"
transferRx?: number // bytes transferRx?: number // bytes
transferTx?: number // bytes transferTx?: number // bytes
persistentKeepalive?: number
persistent?: boolean persistent?: boolean
comment?: string comment?: string
disabled?: boolean
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
} }
export interface WireGuardInterface { export interface WireGuardInterface {
id: string id: string
rosId?: string
name: string // e.g. "wg-msk-spb" name: string // e.g. "wg-msk-spb"
listenPort: number // default 13231 listenPort: number // default 13231
mtu: number // 1420 default in ROS 7.x mtu: number // 1420 default in ROS 7.x
publicKey?: string
privateKey?: string
address?: string
peers: WireGuardPeer[] peers: WireGuardPeer[]
comment: string comment: string
enabled: boolean enabled: boolean
+56 -57
View File
@@ -10,6 +10,7 @@ import {
} from "react" } from "react"
import { useDataSource } from "@/lib/data-source" import { useDataSource } from "@/lib/data-source"
import type { Domain, IpRange, Asn } from "@/lib/data" import type { Domain, IpRange, Asn } from "@/lib/data"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
export interface EvoBgpCommunityRow { export interface EvoBgpCommunityRow {
id: string id: string
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null) const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
function errorMessage(e: unknown, fallback: string): string {
if (e instanceof ApiClientError) return e.message || fallback
if (e instanceof Error) return e.message || fallback
return fallback
}
export function EvoBGPProvider({ children }: { children: React.ReactNode }) { export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const { mode, backendUrl, backendStatus } = useDataSource() const { mode, backendUrl, backendStatus } = useDataSource()
const [baseUrl, setBaseUrlState] = useState("") const [baseUrl, setBaseUrlState] = useState("")
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, { const data = await requestJson<EvoBgpCatalogSnapshot>(
method: "POST", backendUrl,
}) "/api/evobgp/catalog",
const text = await res.text() { method: "POST" },
if (!res.ok) { )
let msg = res.statusText setSnapshot(data)
try {
const j = JSON.parse(text) as { error?: string; detail?: string }
msg = j.error ?? j.detail ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Ошибка EvoBGP")
}
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
} catch (e) { } catch (e) {
setSnapshot(null) setSnapshot(null)
setError(e instanceof Error ? e.message : "Ошибка загрузки") setError(errorMessage(e, "Ошибка загрузки"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
return return
} }
try { try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`) const data = await requestJson<EvoBgpSettingsDto>(
if (!res.ok) throw new Error(await res.text()) backendUrl,
const data = (await res.json()) as EvoBgpSettingsDto "/api/evobgp/settings",
)
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(Boolean(data.enabled))
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
setSettingsLoaded(true) setSettingsLoaded(true)
await pullCatalog(data.enabled ?? false) setError(null)
} catch { await pullCatalog(Boolean(data.enabled))
} catch (e) {
setSettingsLoaded(true) setSettingsLoaded(true)
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
} }
}, [mode, backendStatus, backendUrl, pullCatalog]) }, [mode, backendStatus, backendUrl, pullCatalog])
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const saveSettings = useCallback( const saveSettings = useCallback(
async (patch: EvoBgpSavePayload) => { async (patch: EvoBgpSavePayload) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, { const data = await requestJson<EvoBgpSettingsDto>(
method: "PUT", backendUrl,
headers: { "Content-Type": "application/json" }, "/api/evobgp/settings",
body: JSON.stringify(patch), {
}) method: "PUT",
const text = await res.text() body: JSON.stringify(patch),
if (!res.ok) { },
let msg = res.statusText )
try { const nextEnabled = Boolean(data.enabled)
const j = JSON.parse(text) as { error?: string }
msg = j.error ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Не удалось сохранить")
}
const data = JSON.parse(text) as EvoBgpSettingsDto
setBaseUrlState(data.baseUrl ?? "") setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false) setEnabledState(nextEnabled)
setSecretConfigured(data.secretConfigured ?? false) setSecretConfigured(Boolean(data.secretConfigured))
await pullCatalog(data.enabled ?? false) setError(null)
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
try {
await pullCatalog(nextEnabled)
} catch {
/* pullCatalog already sets error state */
}
}, },
[backendUrl, pullCatalog], [backendUrl, pullCatalog],
) )
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
await pullCatalog(enabled) await pullCatalog(enabled)
}, [enabled, pullCatalog]) }, [enabled, pullCatalog])
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => { const testConnection = useCallback(
try { async (draft?: EvoBgpTestDraft) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, { try {
method: "POST", await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify(draft ?? {}), body: JSON.stringify(draft ?? {}),
}) })
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string } return { ok: true, message: "Соединение с EvoBGP установлено" }
if (!res.ok) throw new Error(data.error ?? res.statusText) } catch (e) {
return { ok: true, message: "Соединение с EvoBGP установлено" } return { ok: false, message: errorMessage(e, "Ошибка") }
} catch (e) { }
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" } },
} [backendUrl],
}, [backendUrl]) )
const value = useMemo( const value = useMemo(
() => ({ () => ({
+2
View File
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
uptimeSpeedProbes: number uptimeSpeedProbes: number
monitoringItems: number monitoringItems: number
recursiveRoutes: number recursiveRoutes: number
certificates?: number
wireguard?: number
} }
+339
View File
@@ -0,0 +1,339 @@
/**
* Client-side WireGuard config codecs (mirror of backend wireguard-config).
* Used for mock preview / offline export without hitting the API.
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
disabled?: boolean
}
export type WgParsedInterface = {
name: string
listenPort?: number
mtu?: number
privateKey?: string
comment?: string
address?: string
disabled?: boolean
}
export type WgParsedConfig = {
format: "rsc" | "conf"
interface: WgParsedInterface
peers: WgParsedPeer[]
}
export type WgExportIface = {
name: string
listenPort: number
mtu: number
comment?: string
enabled?: boolean
privateKey?: string
publicKey?: string
address?: string
serverName?: string
peers: Array<{
publicKey: string
allowedIps: string[]
endpoint?: string
persistentKeepalive?: number
persistent?: boolean
comment?: string
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}>
}
function stripQuotes(v: string): string {
const t = v.trim()
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
function parseKvLine(line: string): Record<string, string> {
const out: Record<string, string> = {}
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
out[m[1]] = stripQuotes(m[2])
}
return out
}
function joinContinuedLines(text: string): string[] {
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
const lines: string[] = []
let buf = ""
for (const line of raw) {
const trimmedEnd = line.replace(/\s+$/, "")
if (trimmedEnd.endsWith("\\")) {
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
continue
}
buf += trimmedEnd
if (buf.trim()) lines.push(buf.trim())
buf = ""
}
if (buf.trim()) lines.push(buf.trim())
return lines
}
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
const t = content.trim()
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
return "rsc"
}
export function parseNativeConf(content: string): WgParsedConfig {
const lines = content.replace(/\r\n/g, "\n").split("\n")
let section: "interface" | "peer" | null = null
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let currentPeer: WgParsedPeer | null = null
const flushPeer = () => {
if (currentPeer?.publicKey) peers.push(currentPeer)
currentPeer = null
}
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith("#") || line.startsWith(";")) continue
if (/^\[Interface\]$/i.test(line)) {
flushPeer()
section = "interface"
continue
}
if (/^\[Peer\]$/i.test(line)) {
flushPeer()
section = "peer"
currentPeer = { publicKey: "", allowedAddresses: [] }
continue
}
const eq = line.indexOf("=")
if (eq < 0) continue
const key = line.slice(0, eq).trim().toLowerCase()
const value = line.slice(eq + 1).trim()
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
else if (key === "allowedips") {
currentPeer.allowedAddresses = value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
} else if (key === "endpoint") {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
}
}
}
flushPeer()
return { format: "conf", interface: iface, peers }
}
export function parseMikrotikRsc(content: string): WgParsedConfig {
const lines = joinContinuedLines(content)
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let foundIface = false
for (const line of lines) {
if (line.startsWith("#")) continue
const lower = line.toLowerCase()
if (
lower.startsWith("/interface wireguard add") ||
lower.startsWith("/interface/wireguard add")
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
foundIface = true
continue
}
if (
lower.startsWith("/interface wireguard peers add") ||
lower.startsWith("/interface/wireguard/peers add")
) {
const kv = parseKvLine(line)
const allowed = (kv["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
: undefined,
comment: kv.comment,
name: kv.name,
clientAddress: kv["client-address"],
clientDns: kv["client-dns"],
clientEndpoint: kv["client-endpoint"],
disabled: kv.disabled === "yes",
})
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
const kv = parseKvLine(line)
if (kv.address) iface.address = kv.address
}
}
if (!foundIface && peers.length === 0) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
}
export function parseWgConfig(
content: string,
format: "auto" | "rsc" | "conf" = "auto",
): WgParsedConfig {
const detected = format === "auto" ? detectWgConfigFormat(content) : format
if (detected === "conf") return parseNativeConf(content)
return parseMikrotikRsc(content)
}
export function generateNativeConf(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`[Interface]`)
if (iface.privateKey) lines.push(`PrivateKey = ${iface.privateKey}`)
else lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`[Peer]`)
lines.push(`PublicKey = ${p.publicKey}`)
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
if (p.comment) lines.push(`# ${p.comment}`)
lines.push(``)
}
return lines.join("\n").trimEnd() + "\n"
}
export function generateMikrotikRsc(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
lines.push(`# RouterOS 7.x · MikrotikManager`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
if (iface.enabled === false) lines.push(` disabled=yes \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(` address=${iface.address} \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) {
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
const port = p.endpoint.includes(":")
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
: "13231"
lines.push(` endpoint-address=${host} \\`)
lines.push(` endpoint-port=${port} \\`)
}
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
if (p.name) lines.push(` name=${p.name} \\`)
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
}
return lines.join("\n")
}
export function generatePeerClientConf(args: {
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(`# PrivateKey = <ключ клиента>`)
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
lines.push(``)
lines.push(`[Peer]`)
lines.push(`PublicKey = ${args.serverPublicKey}`)
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
}
lines.push(``)
return lines.join("\n")
}
+4
View File
@@ -33,6 +33,10 @@
"./backups": { "./backups": {
"types": "./dist/backups.d.ts", "types": "./dist/backups.d.ts",
"default": "./dist/backups.js" "default": "./dist/backups.js"
},
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
} }
}, },
"dependencies": { "dependencies": {
+1
View File
@@ -3,3 +3,4 @@ export * from "./alerts.js"
export * from "./events.js" export * from "./events.js"
export * from "./certificates.js" export * from "./certificates.js"
export * from "./backups.js" export * from "./backups.js"
export * from "./wireguard.js"
+171
View File
@@ -0,0 +1,171 @@
import { z } from "zod"
export const wgStatusSchema = z.enum(["up", "down"])
export const wgPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
publicKey: z.string(),
allowedIps: z.array(z.string()),
endpoint: z.string().optional(),
latestHandshake: z.string().optional(),
transferRx: z.number().nonnegative().optional(),
transferTx: z.number().nonnegative().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
persistent: z.boolean().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
name: z.string().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgIfaceDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
name: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
listenPort: z.number().int().positive(),
mtu: z.number().int().positive(),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
address: z.string().optional(),
peers: z.array(wgPeerDtoSchema),
comment: z.string(),
enabled: z.boolean(),
status: wgStatusSchema,
})
export const wgListResponseSchema = z.object({
interfaces: z.array(wgIfaceDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const wgCreatePeerSchema = z.object({
publicKey: z.string().min(1),
allowedAddresses: z.array(z.string().min(1)).min(1),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
privateKey: z.enum(["auto", "none"]).or(z.string().min(1)).optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
disabled: z.boolean().optional(),
})
export const wgCreateInterfaceSchema = z.object({
serverId: z.union([z.string(), z.number()]),
name: z.string().min(1).max(64),
listenPort: z.number().int().positive().default(13231),
mtu: z.number().int().positive().default(1420),
comment: z.string().optional(),
privateKey: z.string().min(1).optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
peer: wgCreatePeerSchema.optional(),
})
export const wgPatchInterfaceSchema = z.object({
name: z.string().min(1).max(64).optional(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
privateKey: z.string().min(1).optional(),
})
export const wgCreatePeerRequestSchema = wgCreatePeerSchema.extend({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
})
export const wgPatchPeerSchema = z.object({
publicKey: z.string().min(1).optional(),
allowedAddresses: z.array(z.string().min(1)).min(1).optional(),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgImportFormatSchema = z.enum(["auto", "rsc", "conf"])
export const wgImportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
content: z.string().min(1),
format: wgImportFormatSchema.optional().default("auto"),
dryRun: z.boolean().optional().default(false),
})
export const wgExportFormatSchema = z.enum(["rsc", "conf", "peer-conf"])
export const wgExportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
format: wgExportFormatSchema,
peerId: z.string().optional(),
includePrivateKey: z.boolean().optional().default(false),
})
export const wgImportPreviewSchema = z.object({
format: z.enum(["rsc", "conf"]),
interface: z.object({
name: z.string(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
privateKey: z.string().optional(),
comment: z.string().optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
}),
peers: z.array(wgCreatePeerSchema),
})
export const wgImportResponseSchema = z.object({
dryRun: z.boolean(),
preview: wgImportPreviewSchema,
applied: z
.object({
interfaceName: z.string(),
peersCreated: z.number().int().nonnegative(),
})
.optional(),
})
export const wgExportResponseSchema = z.object({
format: wgExportFormatSchema,
filename: z.string(),
content: z.string(),
})
export type WgPeerDto = z.infer<typeof wgPeerDtoSchema>
export type WgIfaceDto = z.infer<typeof wgIfaceDtoSchema>
export type WgListResponse = z.infer<typeof wgListResponseSchema>
export type WgCreateInterface = z.infer<typeof wgCreateInterfaceSchema>
export type WgPatchInterface = z.infer<typeof wgPatchInterfaceSchema>
export type WgCreatePeerRequest = z.infer<typeof wgCreatePeerRequestSchema>
export type WgPatchPeer = z.infer<typeof wgPatchPeerSchema>
export type WgImportRequest = z.infer<typeof wgImportRequestSchema>
export type WgExportRequest = z.infer<typeof wgExportRequestSchema>
export type WgImportPreview = z.infer<typeof wgImportPreviewSchema>
export type WgImportResponse = z.infer<typeof wgImportResponseSchema>
export type WgExportResponse = z.infer<typeof wgExportResponseSchema>
+64 -12
View File
@@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "") return baseUrl.replace(/\/$/, "")
} }
function resolveRequestUrl(baseUrl: string, path: string): string { /** Absolute or same-origin-relative URL for backend API paths. */
export function resolveApiUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") { if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path return path
} }
// Safety: never call browser localhost when the UI is served from a remote host
if (typeof window !== "undefined") {
const host = window.location.hostname
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
const baseIsLocal =
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
return path.startsWith("/") ? path : `/${path}`
}
}
return trimBaseUrl(baseUrl) + path return trimBaseUrl(baseUrl) + path
} }
/** Attach portal JWT when present. */
export function withAuthHeaders(init?: HeadersInit): Headers {
const headers = new Headers(init)
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
return headers
}
function handleUnauthorized(): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
}
throw new ApiClientError("Unauthorized", 401)
}
async function parseErrorMessage(res: Response): Promise<string> {
const payload = await res.json().catch(() => undefined)
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
) {
return (payload as { error: string }).error
}
return res.statusText || `HTTP ${res.status}`
}
export async function requestJson<T>( export async function requestJson<T>(
baseUrl: string, baseUrl: string,
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
const hasBody = init?.body != null const hasBody = init?.body != null
const headers = new Headers(init?.headers) const headers = withAuthHeaders(init?.headers)
if (hasBody && !headers.has("Content-Type")) { if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json") headers.set("Content-Type", "application/json")
} }
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
const res = await fetch(resolveRequestUrl(baseUrl, path), { const res = await fetch(resolveApiUrl(baseUrl, path), {
...init, ...init,
headers, headers,
}) })
if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) { if (res.status === 401) handleUnauthorized()
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
throw new ApiClientError("Unauthorized", 401)
}
if (res.status === 204) return undefined as T if (res.status === 204) return undefined as T
@@ -70,3 +104,21 @@ export async function requestJson<T>(
return payload as T return payload as T
} }
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
export async function requestBlob(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<Response> {
const headers = withAuthHeaders(init?.headers)
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
return res
}
+3 -37
View File
@@ -1,19 +1,7 @@
import { ApiClientError } from "@/shared/api/http-client" import { ApiClientError, requestBlob } from "@/shared/api/http-client"
import { configuredBackendUrl } from "@/lib/backend-url"
const MAX_RESTORE_BYTES = 512 * 1024 * 1024 const MAX_RESTORE_BYTES = 512 * 1024 * 1024
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
if (configuredBackendUrl().kind === "same-origin") {
return path
}
return `${trimBaseUrl(baseUrl)}${path}`
}
function parseFilename(contentDisposition: string | null, fallback: string): string { function parseFilename(contentDisposition: string | null, fallback: string): string {
if (!contentDisposition) return fallback if (!contentDisposition) return fallback
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
export async function downloadSystemDatabaseBackup( export async function downloadSystemDatabaseBackup(
baseUrl: string, baseUrl: string,
): Promise<{ blob: Blob; filename: string }> { ): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup")) const res = await requestBlob(baseUrl, "/api/system/database/backup")
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
const blob = await res.blob() const blob = await res.blob()
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db") const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
return { blob, filename } return { blob, filename }
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
413, 413,
) )
} }
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), { await requestBlob(baseUrl, "/api/system/database/restore", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/octet-stream" }, headers: { "Content-Type": "application/octet-stream" },
body: file, body: file,
}) })
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
} }
+107
View File
@@ -0,0 +1,107 @@
import type {
WgCreateInterface,
WgCreatePeerRequest,
WgExportRequest,
WgExportResponse,
WgImportRequest,
WgImportResponse,
WgListResponse,
WgPatchInterface,
WgPatchPeer,
} from "@mmapp/contracts/wireguard"
import { requestJson } from "@/shared/api/http-client"
export async function listWireGuard(
baseUrl: string,
opts?: { serverId?: string; includePrivateKey?: boolean },
): Promise<WgListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
if (opts?.includePrivateKey) q.set("includePrivateKey", "1")
const qs = q.toString()
return requestJson<WgListResponse>(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`)
}
export async function createWireGuardInterface(
baseUrl: string,
payload: WgCreateInterface,
): Promise<unknown> {
return requestJson(baseUrl, "/api/wireguard/interfaces", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchInterface,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function createWireGuardPeer(
baseUrl: string,
payload: WgCreatePeerRequest,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, "/api/wireguard/peers", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchPeer,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function importWireGuard(
baseUrl: string,
payload: WgImportRequest,
): Promise<WgImportResponse> {
return requestJson(baseUrl, "/api/wireguard/import", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function exportWireGuard(
baseUrl: string,
payload: WgExportRequest,
): Promise<WgExportResponse> {
return requestJson(baseUrl, "/api/wireguard/export", {
method: "POST",
body: JSON.stringify(payload),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long