Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd | ||
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 |
@@ -12,9 +12,9 @@
|
|||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
||||||
"WebFetch(domain:git.shts.su)",
|
"WebFetch(domain:git.shx.one)",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
||||||
"Bash(node -e ' *)",
|
"Bash(node -e ' *)",
|
||||||
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
||||||
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ jobs:
|
|||||||
STAGING=".ci/docker/backend"
|
STAGING=".ci/docker/backend"
|
||||||
rm -rf "$STAGING"
|
rm -rf "$STAGING"
|
||||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||||
cp package.json package-lock.json "$STAGING/"
|
cp package-lock.json "$STAGING/"
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||||
|
pkg.workspaces = ["packages/*", "backend"]
|
||||||
|
pkg.dependencies = {}
|
||||||
|
pkg.devDependencies = {}
|
||||||
|
delete pkg.scripts
|
||||||
|
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||||
|
NODE
|
||||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
@@ -70,6 +79,7 @@ jobs:
|
|||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -177,6 +187,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
|
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
|||||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||||
|
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||||
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
||||||
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||||
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
||||||
|
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
|
||||||
COPY packages/contracts packages/contracts
|
COPY packages/contracts packages/contracts
|
||||||
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||||
COPY app app
|
COPY app app
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ sequenceDiagram
|
|||||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||||
|
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
@@ -219,7 +220,62 @@ npm --prefix backend run db:studio
|
|||||||
|
|
||||||
## Прод-развёртывание Docker
|
## Прод-развёртывание Docker
|
||||||
|
|
||||||
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
|
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
|
||||||
|
|
||||||
|
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
|
||||||
|
|
||||||
|
### CDN Manager + MikrotikManager (один Traefik)
|
||||||
|
|
||||||
|
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
|
||||||
|
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||||
|
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||||
|
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
|
||||||
|
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||||
|
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
|
||||||
|
docker login git.shx.one
|
||||||
|
cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||||
|
curl -fsS https://cdn.shnt.top/health
|
||||||
|
curl -fsS https://mm.shnt.top/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
|
||||||
|
|
||||||
|
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
|
||||||
|
|
||||||
|
| Способ | Файл |
|
||||||
|
|--------|------|
|
||||||
|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
|
||||||
|
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
|
||||||
|
|
||||||
|
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose (рекомендуется)
|
||||||
|
mkdir -p /opt/mmapp/{data,state,updater}
|
||||||
|
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||||
|
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
|
||||||
|
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||||
|
cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
|
||||||
|
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||||
|
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||||
|
chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||||
|
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
|
||||||
|
|
||||||
|
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
|
||||||
|
|
||||||
|
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
|
||||||
|
|
||||||
### Прод-контейнеры
|
### Прод-контейнеры
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
TerminalIcon, AlertCircleIcon,
|
TerminalIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
function ExportSheet({ open, container, onClose }: {
|
function ExportSheet({ open, container, onClose }: {
|
||||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт Container"
|
||||||
<div>
|
description="RouterOS 7.4+ · /container · /interface/veth"
|
||||||
<SheetTitle>Экспорт Container</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${container?.name ?? "container"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link"
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
@@ -104,7 +104,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{t.skipped ? (
|
{t.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -126,7 +126,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_resources") {
|
if (snap.job === "uptime_resources") {
|
||||||
const u = snap as ResourcesRunSnapshot
|
const u = snap as ResourcesRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{u.skipped ? (
|
{u.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -148,7 +148,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "servers_rest_ping") {
|
if (snap.job === "servers_rest_ping") {
|
||||||
const s = snap as ServersRestPingRunSnapshot
|
const s = snap as ServersRestPingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{s.skipped ? (
|
{s.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -171,7 +171,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_ping") {
|
if (snap.job === "uptime_ping") {
|
||||||
const p = snap as PingRunSnapshot
|
const p = snap as PingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{p.skipped ? (
|
{p.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -196,7 +196,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_speed") {
|
if (snap.job === "uptime_speed") {
|
||||||
const s = snap as SpeedScheduledRunSnapshot
|
const s = snap as SpeedScheduledRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||||
</p>
|
</p>
|
||||||
@@ -209,7 +209,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "gre_bgp") {
|
if (snap.job === "gre_bgp") {
|
||||||
const g = snap as GreBgpSnapshotRunSnapshot
|
const g = snap as GreBgpSnapshotRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{g.skipped ? (
|
{g.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -235,7 +235,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{g.errors?.length ? (
|
{g.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{g.errors.map((e, i) => (
|
{g.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -249,7 +249,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "internet_path") {
|
if (snap.job === "internet_path") {
|
||||||
const p = snap as InternetPathRunSnapshot
|
const p = snap as InternetPathRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок internet-path на{" "}
|
Снимок internet-path на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -276,7 +276,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "certificates_renew") {
|
if (snap.job === "certificates_renew") {
|
||||||
const c = snap as CertificatesRenewRunSnapshot
|
const c = snap as CertificatesRenewRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{c.skipped ? (
|
{c.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -299,7 +299,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{c.errors.length ? (
|
{c.errors.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{c.errors.map((e, i) => (
|
{c.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -311,7 +311,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "backups") {
|
if (snap.job === "backups") {
|
||||||
const b = snap as BackupsRunSnapshot
|
const b = snap as BackupsRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||||
@@ -331,7 +331,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{b.errors?.length ? (
|
{b.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{b.errors.map((e, i) => (
|
{b.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -343,7 +343,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "alert_engine") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок на{" "}
|
Снимок на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -370,7 +370,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{a.errors?.length ? (
|
{a.errors?.length ? (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertDescription className="space-y-1 text-xs">
|
<AlertDescription className="flex flex-col gap-1 text-xs">
|
||||||
{a.errors.map((e, i) => (
|
{a.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -380,7 +380,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
|
||||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||||
@@ -398,39 +398,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
||||||
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
||||||
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 space-y-1">
|
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span className="font-medium">{jobTitle}</span>
|
<span className="font-medium">{jobTitle}</span>
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
||||||
<dd className="tabular-nums">
|
<dd className="tabular-nums">
|
||||||
<span className="font-mono">{r.durationMs}</span> мс
|
<span className="font-mono">{r.durationMs}</span> мс
|
||||||
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -447,7 +447,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{r.error ? (
|
{r.error ? (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
||||||
<pre
|
<pre
|
||||||
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
||||||
@@ -461,7 +461,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<SnapshotTables snap={snapshot} />
|
<SnapshotTables snap={snapshot} />
|
||||||
@@ -1090,7 +1090,7 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3 px-5 py-4">
|
<div className="flex flex-col gap-3 px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
@@ -1142,7 +1142,7 @@ export default function DataCollectionPage() {
|
|||||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||||
Сохранить настройки планировщика
|
Сохранить настройки планировщика
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
|
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||||
<p>
|
<p>
|
||||||
Трафик — последний сбор:{" "}
|
Трафик — последний сбор:{" "}
|
||||||
{trafficCollector?.lastCollectedAt
|
{trafficCollector?.lastCollectedAt
|
||||||
|
|||||||
+20
-89
@@ -35,6 +35,7 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -754,13 +755,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
tunnelsList: GreTunnel[]
|
tunnelsList: GreTunnel[]
|
||||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const singleRuleset = useMemo(
|
const singleRuleset = useMemo(
|
||||||
() => rulesets.filter(r => r.serverId === serverId),
|
() => rulesets.filter(r => r.serverId === serverId),
|
||||||
[rulesets, serverId],
|
[rulesets, serverId],
|
||||||
)
|
)
|
||||||
const server = serversList.find(s => s.id === serverId)
|
const server = serversList.find(s => s.id === serverId)
|
||||||
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
||||||
|
|
||||||
const config = useMemo(
|
const config = useMemo(
|
||||||
@@ -771,93 +770,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(config).catch(() => {})
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<CodeExportSheet
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
open={open}
|
||||||
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
|
onClose={onClose}
|
||||||
|
title="RouterOS config"
|
||||||
{/* header */}
|
description={
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
|
server
|
||||||
<div className="flex items-center gap-2.5">
|
? `${server.name} · ${totalRules} правил`
|
||||||
<FileCodeIcon className="size-4 text-muted-foreground" />
|
: "Экспорт правил фильтрации"
|
||||||
<span className="text-sm font-semibold">RouterOS config</span>
|
}
|
||||||
{server && (
|
formats={[
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
{
|
||||||
<Flag code={server.country} size={11} />
|
id: "rsc",
|
||||||
<span className="font-mono">{server.name}</span>
|
label: "RouterOS",
|
||||||
<span>· {totalRules} правил</span>
|
filename: `filters-${server?.name ?? "server"}.rsc`,
|
||||||
</span>
|
code: config,
|
||||||
)}
|
},
|
||||||
</div>
|
]}
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
|
||||||
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
|
|
||||||
{config.split("\n").map((line, i) => {
|
|
||||||
const trimmed = line.trimStart()
|
|
||||||
const cls =
|
|
||||||
// section dividers
|
|
||||||
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
|
|
||||||
? "text-[#444c56]" :
|
|
||||||
// inline comments inside rule body
|
|
||||||
trimmed.startsWith("# ")
|
|
||||||
? "text-[#8b949e]" :
|
|
||||||
// RouterOS scripting keywords
|
|
||||||
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
|
|
||||||
? "text-[#d2a8ff]" :
|
|
||||||
// :if identity branch / closing brace
|
|
||||||
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// filter rule add command
|
|
||||||
trimmed.startsWith("/routing filter rule")
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// rule body: if/else if branches
|
|
||||||
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
|
|
||||||
? "text-[#ff7b72]" :
|
|
||||||
// rule body: blackhole action
|
|
||||||
trimmed.startsWith("set type blackhole")
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// rule body: set actions
|
|
||||||
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
// rule body: accept / rule close
|
|
||||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// named params
|
|
||||||
trimmed.match(/^(chain|comment|bgp-communities)=/)
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
"text-[#c9d1d9]"
|
|
||||||
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* footer */}
|
|
||||||
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<p>1. Скопируйте скрипт в буфер обмена</p>
|
|
||||||
<p>3. Вставьте в терминал и нажмите Enter</p>
|
|
||||||
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
|
|
||||||
<p>4. Скрипт сам определит свои правила по identity</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
|
|
||||||
<Button onClick={handleCopy} className="flex-1">
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано!" : "Скопировать"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ import { cn } from "@/lib/utils"
|
|||||||
import {
|
import {
|
||||||
PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
|
PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
|
||||||
ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
|
ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
|
||||||
MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon,
|
MoreHorizontalIcon, PencilIcon, Trash2Icon, CodeXmlIcon,
|
||||||
CheckIcon, PowerIcon, CheckCircleIcon,
|
PowerIcon, CheckCircleIcon,
|
||||||
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
|
PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
|
||||||
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
|
CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -602,68 +603,28 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
function ExportSheet({ open, onClose, rules }: {
|
function ExportSheet({ open, onClose, rules }: {
|
||||||
open: boolean; onClose: () => void; rules: FirewallRule[]
|
open: boolean; onClose: () => void; rules: FirewallRule[]
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
// Derived from open — new timestamp each time modal opens, undefined when closed
|
|
||||||
const timestamp = useMemo(
|
const timestamp = useMemo(
|
||||||
() => open ? new Date().toLocaleString("ru") : undefined,
|
() => open ? new Date().toLocaleString("ru") : undefined,
|
||||||
[open],
|
[open],
|
||||||
)
|
)
|
||||||
|
|
||||||
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
|
const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
const full = generateRsc(rules, new Date().toLocaleString("ru"))
|
|
||||||
navigator.clipboard.writeText(full).then(() => {
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт Firewall"
|
||||||
<div>
|
description="RouterOS .rsc · /ip firewall filter, nat, mangle"
|
||||||
<SheetTitle>Экспорт Firewall</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS .rsc · /ip firewall filter, nat, mangle</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied
|
filename: `firewall-${new Date().toISOString().slice(0, 10)}.rsc`,
|
||||||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
code,
|
||||||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
},
|
||||||
</Button>
|
]}
|
||||||
</div>
|
/>
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isSection = isComment && line.includes("──")
|
|
||||||
const isKey = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isSection ? "text-muted-foreground/50"
|
|
||||||
: isComment ? "text-muted-foreground"
|
|
||||||
: isKey ? "text-sky-400/90"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-85
@@ -31,9 +31,10 @@ import {
|
|||||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
|
||||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -244,7 +245,6 @@ export default function GrePage() {
|
|||||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||||
const [poolOpen, setPoolOpen] = useState(false)
|
const [poolOpen, setPoolOpen] = useState(false)
|
||||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||||
@@ -366,13 +366,10 @@ export default function GrePage() {
|
|||||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleCopy(code: string) {
|
const greExportCode = useMemo(
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||||
setCopied(true)
|
[codePreviewTunnel, serverById],
|
||||||
toast.success("Команды скопированы")
|
)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -551,82 +548,47 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
<CodeExportSheet
|
||||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
open={!!codePreviewTunnel}
|
||||||
{codePreviewTunnel && (() => {
|
onClose={() => setCodePreviewTunnel(null)}
|
||||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
title={codePreviewTunnel?.name ?? "GRE"}
|
||||||
return (
|
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||||
<>
|
formats={[
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
{
|
||||||
<div className="flex items-start justify-between gap-4">
|
id: "rsc",
|
||||||
<div>
|
label: "RouterOS",
|
||||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
code: greExportCode,
|
||||||
</div>
|
},
|
||||||
<Button
|
]}
|
||||||
variant="outline" size="sm"
|
beforeCode={
|
||||||
className="shrink-0 gap-1.5"
|
codePreviewTunnel ? (
|
||||||
onClick={() => handleCopy(code)}
|
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||||
>
|
<span className="flex items-center gap-1.5">
|
||||||
{copied
|
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
</span>
|
||||||
</Button>
|
<span className="text-muted-foreground">·</span>
|
||||||
</div>
|
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||||
</SheetHeader>
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="font-mono">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||||
{/* meta strip */}
|
{" → "}
|
||||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
{codePreviewTunnel.remoteAddress}
|
||||||
<span className="flex items-center gap-1.5">
|
</span>
|
||||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
{codePreviewTunnel.ipsec ? (
|
||||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
<>
|
||||||
</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
<span className="text-muted-foreground">·</span>
|
<span className="flex items-center gap-1 text-success">
|
||||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
<LockIcon className="size-3" />
|
||||||
<span className="text-muted-foreground">·</span>
|
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
|
||||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
</span>
|
||||||
{codePreviewTunnel.ipsec && (
|
</>
|
||||||
<>
|
) : null}
|
||||||
<span className="text-muted-foreground">·</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
) : null
|
||||||
</>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code block */}
|
|
||||||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isSection = isComment && line.includes("──")
|
|
||||||
const isKey = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isSection ? "text-muted-foreground/60"
|
|
||||||
: isComment ? "text-muted-foreground"
|
|
||||||
: isKey ? "text-sky-400/90"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}
|
|
||||||
{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать команды"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||||
|
|||||||
+20
-17
@@ -1,5 +1,6 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react"
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
|
|||||||
|
|
||||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DataSourceProvider>
|
<AuthGuard>
|
||||||
<EvoBGPProvider>
|
<DataSourceProvider>
|
||||||
<SidebarProvider
|
<EvoBGPProvider>
|
||||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
<SidebarProvider
|
||||||
>
|
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
>
|
||||||
К содержимому
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
</a>
|
К содержимому
|
||||||
<AppSidebar />
|
</a>
|
||||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
<AppSidebar />
|
||||||
{children}
|
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||||
</SidebarInset>
|
{children}
|
||||||
<CommandPalette />
|
</SidebarInset>
|
||||||
<ReleaseNotesModal />
|
<CommandPalette />
|
||||||
</SidebarProvider>
|
<ReleaseNotesModal />
|
||||||
</EvoBGPProvider>
|
</SidebarProvider>
|
||||||
</DataSourceProvider>
|
</EvoBGPProvider>
|
||||||
|
</DataSourceProvider>
|
||||||
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
+161
-110
@@ -4,10 +4,30 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
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 type { ServerStatus } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
Frame,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import {
|
||||||
|
ServerTileRail,
|
||||||
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
|
import {
|
||||||
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, ServerIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -259,12 +279,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,24 +449,24 @@ 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
|
||||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||||
const [serversLoading, setServersLoading] = useState(false)
|
const [serversLoading, setServersLoading] = useState(false)
|
||||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||||
|
const [railOpen, setRailOpen] = useState(false)
|
||||||
|
|
||||||
// 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 +484,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()
|
||||||
|
|
||||||
@@ -490,6 +512,79 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
return termServers.map((s) => ({
|
||||||
|
id: s.uid,
|
||||||
|
name: s.name,
|
||||||
|
country: s.country || undefined,
|
||||||
|
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||||
|
enabled: s.enabled,
|
||||||
|
selectable: s.enabled && s.status !== "offline",
|
||||||
|
title: [s.name, s.host].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [termServers])
|
||||||
|
|
||||||
|
function handleSelectServer(id: string) {
|
||||||
|
setSelectedUid(id)
|
||||||
|
setRefreshKey((k) => k + 1)
|
||||||
|
setRailOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const railHeaderRight = isLive
|
||||||
|
? serversLoading
|
||||||
|
? <Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
: <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const rail = (
|
||||||
|
<ServerTileRail
|
||||||
|
items={railItems}
|
||||||
|
selectedId={selected?.uid ?? selectedUid}
|
||||||
|
onSelect={handleSelectServer}
|
||||||
|
showAll={false}
|
||||||
|
showCount={false}
|
||||||
|
showType={false}
|
||||||
|
headerRight={railHeaderRight}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
function QuickCmds() {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||||
|
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||||
|
<FrameHeader className="border-b px-3 py-2">
|
||||||
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Быстрые команды
|
||||||
|
</FrameTitle>
|
||||||
|
</FrameHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-0.5 p-1.5">
|
||||||
|
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
type="button"
|
||||||
|
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() => injectCommand(cmd)}
|
||||||
|
title={cmd}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||||
|
<p>↑↓ — история команд</p>
|
||||||
|
<p>Ctrl+L — очистить экран</p>
|
||||||
|
{isLive
|
||||||
|
? <p className="text-info">Команды выполняются на роутере</p>
|
||||||
|
: <p>Режим: mock-данные</p>}
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function injectCommand(cmd: string) {
|
function injectCommand(cmd: string) {
|
||||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -500,108 +595,42 @@ export default function TerminalPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
<>
|
||||||
setRefreshKey(k => k + 1)
|
<Button
|
||||||
if (isLive) setSelectedUid("")
|
size="sm"
|
||||||
}}>
|
variant="outline"
|
||||||
<RefreshCwIcon className="size-4" />Переподключить
|
className="md:hidden"
|
||||||
</Button>
|
onClick={() => setRailOpen(true)}
|
||||||
|
>
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
{selected?.name ?? "Сервер"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setRefreshKey((k) => k + 1)
|
||||||
|
if (isLive) setSelectedUid("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
Переподключить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden p-6">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||||
|
{rail}
|
||||||
|
<QuickCmds />
|
||||||
|
</aside>
|
||||||
|
|
||||||
{/* ── sidebar ── */}
|
<div className="min-w-0 flex-1 overflow-hidden p-3 md:p-4">
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
|
||||||
|
|
||||||
{/* server picker */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
|
||||||
{isLive && serversLoading && (
|
|
||||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
{isLive && !serversLoading && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground px-2.5">
|
|
||||||
Нет доступных серверов
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{termServers.map(s => {
|
|
||||||
const isOffline = s.status === "offline"
|
|
||||||
const isSelected = s.uid === selectedUid
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.uid}
|
|
||||||
disabled={isOffline || !s.enabled}
|
|
||||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
|
||||||
"flex items-center gap-2",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full shrink-0",
|
|
||||||
s.status === "online" ? "bg-emerald-500" :
|
|
||||||
s.status === "degraded" ? "bg-amber-400" :
|
|
||||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
|
||||||
)} />
|
|
||||||
{s.country && <Flag code={s.country} />}
|
|
||||||
<span className="truncate font-mono">{s.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* quick commands */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
||||||
Быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
||||||
<button
|
|
||||||
key={cmd}
|
|
||||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
|
||||||
onClick={() => injectCommand(cmd)}
|
|
||||||
title={cmd}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* hints */}
|
|
||||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
|
||||||
<p>↑↓ — история команд</p>
|
|
||||||
<p>Ctrl+L — очистить экран</p>
|
|
||||||
{isLive
|
|
||||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
|
||||||
: <p>Режим: mock-данные</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── terminal ── */}
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Terminal
|
<Terminal
|
||||||
key={termKey}
|
key={termKey}
|
||||||
@@ -610,12 +639,34 @@ export default function TerminalPage() {
|
|||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||||
|
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||||
|
<SheetHeader className="px-1 pt-1">
|
||||||
|
<SheetTitle>Серверы</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
|
<ServerTileRail
|
||||||
|
items={railItems}
|
||||||
|
selectedId={selected?.uid ?? selectedUid}
|
||||||
|
onSelect={handleSelectServer}
|
||||||
|
showAll={false}
|
||||||
|
showCount={false}
|
||||||
|
showType={false}
|
||||||
|
showHeader={false}
|
||||||
|
headerRight={railHeaderRight}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
/>
|
||||||
|
<QuickCmds />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-54
@@ -13,13 +13,9 @@ import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||||
CodeXmlIcon, LayersIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
|
||||||
} from "@/components/ui/sheet"
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,57 +66,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
|||||||
function ExportSheet({ open, tunnel, onClose }: {
|
function ExportSheet({ open, tunnel, onClose }: {
|
||||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||||
|
|
||||||
function handleCopy() {
|
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт VXLAN"
|
||||||
<div>
|
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+805
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export default function AccessDeniedPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||||
|
необходимых прав. Обратитесь к администратору auth-portal.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/auth/callback"
|
||||||
|
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Войти снова
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
clearPortalHandoffFlag,
|
||||||
|
clearToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
parseHashToken,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
|
setToken,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const error = params.get("error")
|
||||||
|
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
|
if (accessToken) {
|
||||||
|
setToken(accessToken)
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes("mm")) {
|
||||||
|
setMessage("Нет доступа к приложению")
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/config", {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore network — proceed */
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = firstAllowedPath()
|
||||||
|
if (next === "/access-denied") {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getToken() && getClaims()) {
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
if (!getClaims()!.apps.includes("mm")) {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(firstAllowedPath())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,3 +6,10 @@ PORT=8000
|
|||||||
|
|
||||||
# Allowed CORS origin (Next.js frontend)
|
# Allowed CORS origin (Next.js frontend)
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Portal SSO (false = open API for local/dev)
|
||||||
|
AUTH_REQUIRED=false
|
||||||
|
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
|||||||
+19
-2
@@ -5,10 +5,23 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY packages/contracts/package.json packages/contracts/
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
COPY backend/package.json backend/
|
COPY backend/package.json backend/
|
||||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||||
|
RUN node -e "\
|
||||||
|
const fs=require('fs');\
|
||||||
|
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||||
|
p.dependencies={};\
|
||||||
|
p.devDependencies={};\
|
||||||
|
delete p.scripts;\
|
||||||
|
p.workspaces=['packages/*','backend'];\
|
||||||
|
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||||
|
"
|
||||||
|
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||||
|
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||||
|
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||||
&& npm rebuild better-sqlite3
|
&& npm rebuild better-sqlite3
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
|||||||
COPY backend backend
|
COPY backend backend
|
||||||
RUN npm run build -w @mmapp/contracts \
|
RUN npm run build -w @mmapp/contracts \
|
||||||
&& npm run build -w mikrotik-manager-backend \
|
&& npm run build -w mikrotik-manager-backend \
|
||||||
&& npm prune --omit=dev
|
&& npm prune --omit=dev \
|
||||||
|
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||||
|
&& mkdir -p backend/node_modules
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runner
|
FROM node:22-bookworm-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
|
|||||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||||
COPY --from=build /app/backend/dist ./backend/dist
|
COPY --from=build /app/backend/dist ./backend/dist
|
||||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["node", "backend/dist/index.js"]
|
CMD ["node", "backend/dist/index.js"]
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"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:wireguard": "npx tsx src/services/wireguard-config.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.2.2",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"pino-pretty": "^13.1.3",
|
"fastify-plugin": "^5.1.0",
|
||||||
"undici": "^8.1.0",
|
"undici": "^8.1.0",
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
},
|
},
|
||||||
@@ -29,6 +32,8 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"jose": "^6.2.11",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -3,17 +3,61 @@ import { z } from "zod"
|
|||||||
|
|
||||||
config()
|
config()
|
||||||
|
|
||||||
|
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (v === undefined || v === "") return fallback
|
||||||
|
return v === "1" || v.toLowerCase() === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||||
PORT: z.coerce.number().int().positive().default(8000),
|
PORT: z.coerce.number().int().positive().default(8000),
|
||||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||||
|
AUTH_REQUIRED: z.boolean().default(false),
|
||||||
|
AUTH_JWT_SECRET: z.string().default(""),
|
||||||
|
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
|
||||||
|
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsed = envSchema.safeParse(process.env)
|
const raw = {
|
||||||
|
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||||
|
PORT: process.env.PORT,
|
||||||
|
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||||
|
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
|
AUTH_JWT_SECRET:
|
||||||
|
process.env.AUTH_JWT_SECRET?.trim() ||
|
||||||
|
process.env.JWT_SECRET?.trim() ||
|
||||||
|
(isProd ? "" : "dev-secret-change-me"),
|
||||||
|
AUTH_ISSUER:
|
||||||
|
process.env.AUTH_ISSUER?.trim() ||
|
||||||
|
process.env.ISSUER?.trim() ||
|
||||||
|
"https://auth.shnt.top",
|
||||||
|
AUTH_PORTAL_URL: (
|
||||||
|
process.env.AUTH_PORTAL_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
|
||||||
|
"http://localhost:5175"
|
||||||
|
).replace(/\/$/, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = envSchema.safeParse(raw)
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = parsed.data
|
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||||
|
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||||
|
PORT: parsed.data.PORT,
|
||||||
|
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||||
|
authRequired: parsed.data.AUTH_REQUIRED,
|
||||||
|
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
|
||||||
|
authIssuer: parsed.data.AUTH_ISSUER,
|
||||||
|
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
|
||||||
|
}
|
||||||
|
|||||||
+103
-62
@@ -1,11 +1,12 @@
|
|||||||
import Fastify from "fastify"
|
import Fastify, { type FastifyInstance } from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||||
import { env } from "./config.js"
|
import { env } from "./config.js"
|
||||||
|
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||||
import serversRoutes from "./routes/servers.js"
|
import serversRoutes from "./routes/servers.js"
|
||||||
import bgpRoutes from "./routes/bgp.js"
|
import bgpRoutes from "./routes/bgp.js"
|
||||||
import ospfRoutes from "./routes/ospf.js"
|
import ospfRoutes from "./routes/ospf.js"
|
||||||
import execRoutes from "./routes/exec.js"
|
import execRoutes from "./routes/exec.js"
|
||||||
import filtersRoutes from "./routes/filters.js"
|
import filtersRoutes from "./routes/filters.js"
|
||||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||||
import trafficRoutes from "./routes/traffic.js"
|
import trafficRoutes from "./routes/traffic.js"
|
||||||
@@ -22,73 +23,113 @@ 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"
|
||||||
|
|
||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
export async function buildApp(opts?: {
|
||||||
|
logger?: boolean
|
||||||
|
startScheduler?: boolean
|
||||||
|
}): Promise<FastifyInstance> {
|
||||||
|
const usePrettyLogger =
|
||||||
|
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||||
|
const app = Fastify({
|
||||||
|
bodyLimit: 512 * 1024 * 1024,
|
||||||
|
requestTimeout: 10 * 60 * 1000,
|
||||||
|
logger:
|
||||||
|
opts?.logger === false
|
||||||
|
? false
|
||||||
|
: usePrettyLogger
|
||||||
|
? {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: "HH:MM:ss",
|
||||||
|
ignore: "pid,hostname",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: true,
|
||||||
|
})
|
||||||
|
|
||||||
const app = Fastify({
|
app.setValidatorCompiler(validatorCompiler)
|
||||||
bodyLimit: 512 * 1024 * 1024,
|
app.setSerializerCompiler(serializerCompiler)
|
||||||
requestTimeout: 10 * 60 * 1000,
|
|
||||||
logger: {
|
|
||||||
transport: {
|
|
||||||
target: "pino-pretty",
|
|
||||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use Zod for request validation and response serialization
|
await app.register(cors, {
|
||||||
app.setValidatorCompiler(validatorCompiler)
|
origin: env.CORS_ORIGIN,
|
||||||
app.setSerializerCompiler(serializerCompiler)
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
})
|
||||||
|
|
||||||
// CORS — allow Next.js frontend
|
await app.register(authPlugin)
|
||||||
await app.register(cors, {
|
|
||||||
origin: env.CORS_ORIGIN,
|
|
||||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
app.get("/health", async () => ({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
|
}))
|
||||||
|
|
||||||
app.get("/health", async () => ({
|
app.get("/api/auth/config", async () => ({
|
||||||
status: "ok",
|
required: env.authRequired,
|
||||||
timestamp: new Date().toISOString(),
|
portal_url: env.authPortalUrl,
|
||||||
version: process.env.APP_VERSION ?? "dev",
|
issuer: env.authIssuer,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
if (env.authRequired) {
|
||||||
await app.register(bgpRoutes, { prefix: "/api" })
|
app.addHook("preHandler", async (request, reply) => {
|
||||||
await app.register(ospfRoutes, { prefix: "/api" })
|
const pathname = request.url.split("?")[0] ?? request.url
|
||||||
await app.register(execRoutes, { prefix: "/api" })
|
if (!pathname.startsWith("/api/")) return
|
||||||
await app.register(filtersRoutes, { prefix: "/api" })
|
if (pathname === "/api/auth/config") return
|
||||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
await requireAuth(request, reply)
|
||||||
await app.register(trafficRoutes, { prefix: "/api" })
|
if (reply.sent) return
|
||||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
})
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
}
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
|
||||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
|
||||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
|
||||||
await app.register(probesRoutes, { prefix: "/api" })
|
|
||||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
|
||||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(alertsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(backupsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
|
||||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
|
||||||
await app.register(eventsRoutes, { prefix: "/api" })
|
|
||||||
|
|
||||||
refreshScheduler()
|
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||||
app.addHook("onClose", async () => {
|
await app.register(bgpRoutes, { prefix: "/api" })
|
||||||
stopScheduler()
|
await app.register(ospfRoutes, { prefix: "/api" })
|
||||||
})
|
await app.register(execRoutes, { prefix: "/api" })
|
||||||
|
await app.register(filtersRoutes, { prefix: "/api" })
|
||||||
|
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||||
|
await app.register(trafficRoutes, { prefix: "/api" })
|
||||||
|
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||||
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
|
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||||
|
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||||
|
await app.register(probesRoutes, { prefix: "/api" })
|
||||||
|
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||||
|
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(alertsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(backupsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||||
|
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||||
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
// ── start ──────────────────────────────────────────────────────────────────────
|
if (opts?.startScheduler !== false) {
|
||||||
|
refreshScheduler()
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
stopScheduler()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
return app
|
||||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
}
|
||||||
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
|
|
||||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
const isMain =
|
||||||
} catch (err) {
|
process.argv[1] &&
|
||||||
app.log.error(err)
|
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||||
process.exit(1)
|
|
||||||
|
if (isMain) {
|
||||||
|
try {
|
||||||
|
const app = await buildApp()
|
||||||
|
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||||
|
console.log(
|
||||||
|
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||||
|
)
|
||||||
|
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { hasPermission, permissionForRequest } from "./permissions.js"
|
||||||
|
|
||||||
|
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
|
||||||
|
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
|
||||||
|
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/servers"),
|
||||||
|
"mm:servers:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/servers"),
|
||||||
|
"mm:servers:write",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/system/database/backup"),
|
||||||
|
"mm:settings:admin",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
|
"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")
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||||
|
* Format: mm:<section>:<read|write|admin>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
apps: string[]
|
||||||
|
permissions: string[]
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true
|
||||||
|
const parts = required.split(":")
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
const [app, section, action] = parts
|
||||||
|
if (action === "read") {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === "write") {
|
||||||
|
return granted.includes(`${app}:${section}:admin`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
methods: string[]
|
||||||
|
match: (path: string) => boolean
|
||||||
|
permission: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/system") ||
|
||||||
|
p.startsWith("/api/scheduler") ||
|
||||||
|
p.startsWith("/api/evobgp"),
|
||||||
|
permission: "mm:settings:admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||||
|
permission: "mm:dashboard:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/servers"),
|
||||||
|
permission: "mm:servers:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/servers"),
|
||||||
|
permission: "mm:servers:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/filters"),
|
||||||
|
permission: "mm:filters:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/filters"),
|
||||||
|
permission: "mm:filters:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/bgp"),
|
||||||
|
permission: "mm:bgp:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/bgp"),
|
||||||
|
permission: "mm:bgp:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/uptime"),
|
||||||
|
permission: "mm:uptime:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/uptime"),
|
||||||
|
permission: "mm:uptime:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/traffic"),
|
||||||
|
permission: "mm:traffic:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/traffic"),
|
||||||
|
permission: "mm:traffic:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/alerts"),
|
||||||
|
permission: "mm:alerts:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/alerts"),
|
||||||
|
permission: "mm:alerts:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/backups"),
|
||||||
|
permission: "mm:backups:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/backups"),
|
||||||
|
permission: "mm:backups:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/certificates"),
|
||||||
|
permission: "mm:certificates:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/certificates"),
|
||||||
|
permission: "mm:certificates:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/network") ||
|
||||||
|
p.startsWith("/api/ospf") ||
|
||||||
|
p.startsWith("/api/recursive") ||
|
||||||
|
p.startsWith("/api/probes") ||
|
||||||
|
p.startsWith("/api/internet-path") ||
|
||||||
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard"),
|
||||||
|
permission: "mm:network:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/network") ||
|
||||||
|
p.startsWith("/api/ospf") ||
|
||||||
|
p.startsWith("/api/recursive") ||
|
||||||
|
p.startsWith("/api/probes") ||
|
||||||
|
p.startsWith("/api/internet-path") ||
|
||||||
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard"),
|
||||||
|
permission: "mm:network:write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||||
|
export function permissionForRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
const m = method.toUpperCase()
|
||||||
|
const pathname = path.split("?")[0] ?? path
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (!rule.methods.includes(m)) continue
|
||||||
|
if (rule.match(pathname)) return rule.permission
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Smoke: AUTH_REQUIRED gate via Fastify inject.
|
||||||
|
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { SignJWT } from "jose"
|
||||||
|
|
||||||
|
process.env.AUTH_REQUIRED = "true"
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
|
||||||
|
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||||
|
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
|
||||||
|
process.env.CORS_ORIGIN = "http://localhost:3000"
|
||||||
|
process.env.DATABASE_PATH = ":memory:"
|
||||||
|
process.env.NODE_ENV = "test"
|
||||||
|
|
||||||
|
// Dynamic import after env is set
|
||||||
|
const { buildApp } = await import("../index.js")
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode("test-secret-at-least-8")
|
||||||
|
|
||||||
|
async function mint(payload: Record<string, unknown>): Promise<string> {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuer("https://auth.test.local")
|
||||||
|
.setExpirationTime("1h")
|
||||||
|
.sign(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await buildApp({ logger: false, startScheduler: false })
|
||||||
|
|
||||||
|
const health = await app.inject({ method: "GET", url: "/health" })
|
||||||
|
assert.equal(health.statusCode, 200)
|
||||||
|
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
|
||||||
|
assert.equal(cfg.statusCode, 200)
|
||||||
|
assert.equal(cfg.json().required, true)
|
||||||
|
|
||||||
|
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
|
||||||
|
assert.equal(noToken.statusCode, 401)
|
||||||
|
|
||||||
|
const badApp = await mint({
|
||||||
|
sub: "u1",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "A",
|
||||||
|
apps: ["cdn"],
|
||||||
|
permissions: ["cdn:dashboard:read"],
|
||||||
|
})
|
||||||
|
const forbiddenApp = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/sidebar-counts",
|
||||||
|
headers: { authorization: `Bearer ${badApp}` },
|
||||||
|
})
|
||||||
|
assert.equal(forbiddenApp.statusCode, 403)
|
||||||
|
|
||||||
|
const okToken = await mint({
|
||||||
|
sub: "u1",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "A",
|
||||||
|
apps: ["mm"],
|
||||||
|
permissions: ["mm:dashboard:read"],
|
||||||
|
})
|
||||||
|
const ok = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/sidebar-counts",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
})
|
||||||
|
// May be 200 or 500 if DB missing — must not be 401/403
|
||||||
|
assert.notEqual(ok.statusCode, 401)
|
||||||
|
assert.notEqual(ok.statusCode, 403)
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
console.log("auth.smoke.test.ts: ok")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import fp from "fastify-plugin"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
permissionForRequest,
|
||||||
|
type AuthUser,
|
||||||
|
} from "../lib/permissions.js"
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
authUser?: AuthUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
apps?: string[]
|
||||||
|
permissions?: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
user: {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
apps?: string[]
|
||||||
|
permissions?: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authPlugin(app: FastifyInstance) {
|
||||||
|
if (env.authRequired && env.jwtSecret.length < 8) {
|
||||||
|
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(import("@fastify/jwt"), {
|
||||||
|
secret: env.jwtSecret,
|
||||||
|
...(env.authRequired
|
||||||
|
? {
|
||||||
|
verify: {
|
||||||
|
allowedIss: [env.authIssuer],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (env.authRequired) {
|
||||||
|
app.log.info(
|
||||||
|
{ issuer: env.authIssuer, portal: env.authPortalUrl },
|
||||||
|
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protect /api/* when AUTH_REQUIRED=true.
|
||||||
|
* Public: /health, /api/auth/config
|
||||||
|
*/
|
||||||
|
export async function requireAuth(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!env.authRequired) return
|
||||||
|
|
||||||
|
const pathname = (request.url.split("?")[0] ?? request.url)
|
||||||
|
if (pathname === "/api/auth/config") return
|
||||||
|
|
||||||
|
const authHeader = request.headers.authorization ?? ""
|
||||||
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.user
|
||||||
|
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
|
||||||
|
const permissions = Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!apps.includes("mm")) {
|
||||||
|
return reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: "Нет доступа к приложению MikrotikManager" })
|
||||||
|
}
|
||||||
|
|
||||||
|
request.authUser = {
|
||||||
|
id: String(payload.sub),
|
||||||
|
email: String(payload.email ?? ""),
|
||||||
|
name: String(payload.name ?? ""),
|
||||||
|
apps,
|
||||||
|
permissions,
|
||||||
|
isAdmin: Boolean(payload.is_admin),
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = permissionForRequest(request.method, pathname)
|
||||||
|
if (required && !hasPermission(permissions, required)) {
|
||||||
|
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(authPlugin, { name: "auth" })
|
||||||
@@ -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-ключ (в форме или уже сохранённые в БД)",
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
ensureAuthConfig,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
if (cancelled) return
|
||||||
|
if (!isAuthEnabled()) {
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!getToken() || !claims) {
|
||||||
|
const ok = redirectToPortalLogin()
|
||||||
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes("mm")) {
|
||||||
|
window.location.assign("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
|
Проверка сессии…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
CompactDataGrid,
|
CompactDataGrid,
|
||||||
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
|
|||||||
errLabel?: string
|
errLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return ok ? (
|
return ok ? (
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
<Badge variant="success-outline" size="sm" className="text-[10px]">
|
||||||
{okLabel}
|
{okLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
|
||||||
{errLabel}
|
{errLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, type ReactNode } from "react"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -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,
|
||||||
@@ -48,17 +48,38 @@ export interface WgIfaceWithServer extends WireGuardInterface {
|
|||||||
|
|
||||||
interface WireguardDataGridProps {
|
interface WireguardDataGridProps {
|
||||||
interfaces: WgIfaceWithServer[]
|
interfaces: WgIfaceWithServer[]
|
||||||
|
compactServer?: boolean
|
||||||
|
emptyAction?: ReactNode
|
||||||
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,
|
||||||
|
compactServer = false,
|
||||||
|
emptyAction,
|
||||||
|
onExport,
|
||||||
|
onAddPeer,
|
||||||
|
onToggle,
|
||||||
|
onDelete,
|
||||||
|
onDeletePeer,
|
||||||
|
onExportPeer,
|
||||||
|
}: WireguardDataGridProps) {
|
||||||
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "name",
|
id: "name",
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
<DataGridSortHeader
|
||||||
|
column={column}
|
||||||
|
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
||||||
|
className="ml-1"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const iface = row.original
|
const iface = row.original
|
||||||
@@ -76,15 +97,17 @@ 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">
|
{!compactServer ? (
|
||||||
<Flag code={iface.serverCountry} size={12} />
|
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||||
{iface.serverName}
|
<Flag code={iface.serverCountry || "UN"} size={12} />
|
||||||
</div>
|
{iface.serverName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<p className="sr-only">
|
<p className="sr-only">
|
||||||
{onlinePeers}/{iface.peers.length} пиров
|
{onlinePeers}/{iface.peers.length} пиров
|
||||||
</p>
|
</p>
|
||||||
@@ -93,11 +116,15 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
headerTitle: "Интерфейс / Сервер",
|
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
|
||||||
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 +163,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 +179,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 +227,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 +266,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[onExport],
|
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -254,7 +284,8 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<ShieldCheckIcon className="size-4" />}
|
icon={<ShieldCheckIcon className="size-4" />}
|
||||||
title="Нет WireGuard интерфейсов"
|
title="Нет WireGuard интерфейсов"
|
||||||
description="Добавьте первый интерфейс или проверьте поиск"
|
description="Добавьте первый интерфейс или сбросьте фильтры"
|
||||||
|
action={emptyAction}
|
||||||
className="border-0 py-16"
|
className="border-0 py-16"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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-success" : "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-success" />
|
||||||
|
{fmtBytes(peer.transferRx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowUpIcon className="size-3 text-info" />
|
||||||
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { WireGuardPeer } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
|
import {
|
||||||
|
DATA_GRID_CELL_PAD,
|
||||||
|
DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
DATA_GRID_CELL_PAD_LAST,
|
||||||
|
} from "@/components/data-grids/shared/data-grid-layout"
|
||||||
|
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CodeXmlIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export interface WgPeerRow extends WireGuardPeer {
|
||||||
|
id: string
|
||||||
|
ifaceId: string
|
||||||
|
ifaceName: string
|
||||||
|
serverId: string
|
||||||
|
serverName: string
|
||||||
|
serverCountry: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireguardPeersGridProps {
|
||||||
|
peers: WgPeerRow[]
|
||||||
|
compactServer?: boolean
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
onDeletePeer?: (row: WgPeerRow) => void
|
||||||
|
onExportPeer?: (row: WgPeerRow) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WireguardPeersGrid({
|
||||||
|
peers,
|
||||||
|
compactServer = false,
|
||||||
|
emptyAction,
|
||||||
|
onDeletePeer,
|
||||||
|
onExportPeer,
|
||||||
|
}: WireguardPeersGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
|
||||||
|
const cols: ColumnDef<WgPeerRow>[] = [
|
||||||
|
{
|
||||||
|
id: "peer",
|
||||||
|
accessorKey: "publicKey",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const peer = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-mono text-xs" title={peer.publicKey}>
|
||||||
|
{peer.name || truncKey(peer.publicKey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Пир",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iface",
|
||||||
|
accessorKey: "ifaceName",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-sm">{row.original.ifaceName}</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Интерфейс",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!compactServer) {
|
||||||
|
cols.push({
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "serverName",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
|
||||||
|
<Flag code={row.original.serverCountry || "UN"} size={12} />
|
||||||
|
{row.original.serverName}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Сервер",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cols.push(
|
||||||
|
{
|
||||||
|
id: "allowedIps",
|
||||||
|
accessorFn: (row) => row.allowedIps.join(", "),
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{row.original.allowedIps.join(", ") || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Allowed IPs",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "handshake",
|
||||||
|
accessorKey: "latestHandshake",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap font-mono text-[11px]",
|
||||||
|
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.latestHandshake ?? "нет рукопожатия"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Handshake",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transfer",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowDownIcon className="size-3 text-success" />
|
||||||
|
{fmtBytes(row.original.transferRx)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<ArrowUpIcon className="size-3 text-info" />
|
||||||
|
{fmtBytes(row.original.transferTx)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "RX / TX",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "endpoint",
|
||||||
|
accessorKey: "endpoint",
|
||||||
|
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{row.original.endpoint ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Endpoint",
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
enableSorting: false,
|
||||||
|
size: 72,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const peer = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-0.5">
|
||||||
|
{onExportPeer ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
aria-label="Экспорт peer .conf"
|
||||||
|
onClick={() => onExportPeer(peer)}
|
||||||
|
>
|
||||||
|
<CodeXmlIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onDeletePeer ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive"
|
||||||
|
aria-label="Удалить пира"
|
||||||
|
onClick={() => onDeletePeer(peer)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return cols
|
||||||
|
}, [compactServer, onDeletePeer, onExportPeer])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: peers,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UsersIcon className="size-4" />}
|
||||||
|
title="Нет пиров"
|
||||||
|
description="Добавьте пира к интерфейсу или сбросьте поиск"
|
||||||
|
action={emptyAction}
|
||||||
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridShell
|
||||||
|
table={table}
|
||||||
|
recordCount={peers.length}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: cn("group/row"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WireguardPeersGrid, type WireguardPeersGridProps }
|
||||||
+42
-3
@@ -5,6 +5,7 @@ import Link from "next/link"
|
|||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import {
|
import {
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
|
LogOutIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
@@ -13,6 +14,12 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
ensureAuthConfig,
|
||||||
|
getClaims,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogout,
|
||||||
|
} from "@/lib/auth"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -96,11 +103,37 @@ function ThemeSegmentedToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initials(name: string, email: string): string {
|
||||||
|
const base = (name || email || "?").trim()
|
||||||
|
const parts = base.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
|
||||||
|
}
|
||||||
|
return base.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
export function NavUser() {
|
export function NavUser() {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const name = "Оператор"
|
const [name, setName] = useState("Оператор")
|
||||||
const email = "локальный доступ"
|
const [email, setEmail] = useState("локальный доступ")
|
||||||
const fallback = "ОП"
|
const [showLogout, setShowLogout] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ensureAuthConfig().then(() => {
|
||||||
|
const claims = getClaims()
|
||||||
|
if (claims) {
|
||||||
|
setName(claims.name || claims.email || "Пользователь")
|
||||||
|
setEmail(claims.email || "")
|
||||||
|
setShowLogout(isAuthEnabled())
|
||||||
|
} else if (isAuthEnabled()) {
|
||||||
|
setName("Сессия")
|
||||||
|
setEmail("требуется вход")
|
||||||
|
setShowLogout(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fallback = initials(name, email)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -155,6 +188,12 @@ export function NavUser() {
|
|||||||
<ThemeSegmentedToggle />
|
<ThemeSegmentedToggle />
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{showLogout ? (
|
||||||
|
<DropdownMenuItem onClick={() => redirectToPortalLogout()}>
|
||||||
|
<LogOutIcon aria-hidden />
|
||||||
|
Выйти
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type CodeExportFormat = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
filename: string
|
||||||
|
code: string
|
||||||
|
/** Empty / unavailable — show warning instead of code */
|
||||||
|
emptyMessage?: string
|
||||||
|
/** Content came from live device fetch */
|
||||||
|
isLive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightLine(line: string): string {
|
||||||
|
const trimmed = line.trimStart()
|
||||||
|
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
|
||||||
|
if (trimmed.startsWith("[")) return "text-info"
|
||||||
|
if (trimmed.startsWith("/")) return "text-info"
|
||||||
|
if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
|
||||||
|
return "text-foreground/90"
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ code }: { code: string }) {
|
||||||
|
const lines = code.length ? code.split("\n") : [""]
|
||||||
|
return (
|
||||||
|
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span key={i} className={cn("block", highlightLine(line))}>
|
||||||
|
{line || " "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeExportSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
formats: CodeExportFormat[]
|
||||||
|
/** Initial format id when sheet opens */
|
||||||
|
initialFormatId?: string
|
||||||
|
/** Optional live fetch from device */
|
||||||
|
onLiveFetch?: (formatId: string) => void
|
||||||
|
liveBusy?: boolean
|
||||||
|
/** Short CTA — keep laconic */
|
||||||
|
liveLabel?: string
|
||||||
|
className?: string
|
||||||
|
/** Extra content under format tabs (e.g. meta strip) */
|
||||||
|
beforeCode?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared code export Sheet — ReUI Frame + sticky footer.
|
||||||
|
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
|
||||||
|
* Alert: https://reui.io/docs/components/base/alert
|
||||||
|
*/
|
||||||
|
function CodeExportSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
formats,
|
||||||
|
initialFormatId,
|
||||||
|
onLiveFetch,
|
||||||
|
liveBusy,
|
||||||
|
liveLabel = "С роутера",
|
||||||
|
className,
|
||||||
|
beforeCode,
|
||||||
|
}: CodeExportSheetProps) {
|
||||||
|
const firstId = formats[0]?.id ?? "default"
|
||||||
|
const [tab, setTab] = useState(initialFormatId ?? firstId)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setTab(
|
||||||
|
initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||||
|
? initialFormatId
|
||||||
|
: firstId,
|
||||||
|
)
|
||||||
|
setCopied(false)
|
||||||
|
}, [open, initialFormatId, firstId, formats])
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => formats.find((f) => f.id === tab) ?? formats[0],
|
||||||
|
[formats, tab],
|
||||||
|
)
|
||||||
|
|
||||||
|
const code = active?.code ?? ""
|
||||||
|
const emptyMessage = active?.emptyMessage
|
||||||
|
const filename = active?.filename ?? "export.txt"
|
||||||
|
const canCopy = Boolean(code) && !emptyMessage
|
||||||
|
const isLive = Boolean(active?.isLive)
|
||||||
|
const showTabs = formats.length > 1
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
if (!canCopy) return
|
||||||
|
void navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<SheetContent
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||||
|
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</SheetTitle>
|
||||||
|
{description ? (
|
||||||
|
<SheetDescription className="font-mono text-xs">
|
||||||
|
{description}
|
||||||
|
</SheetDescription>
|
||||||
|
) : null}
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
|
||||||
|
{showTabs ? (
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(String(v))}
|
||||||
|
className="shrink-0 gap-0"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full">
|
||||||
|
{formats.map((f) => (
|
||||||
|
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||||
|
{f.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onLiveFetch || beforeCode ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{onLiveFetch ? (
|
||||||
|
<Badge
|
||||||
|
variant={isLive ? "success-light" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{isLive ? "С роутера" : "Локально"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{beforeCode}
|
||||||
|
</div>
|
||||||
|
{onLiveFetch ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={liveBusy}
|
||||||
|
onClick={() => onLiveFetch(tab)}
|
||||||
|
aria-label="Подтянуть конфиг с роутера с private-key"
|
||||||
|
>
|
||||||
|
{liveBusy ? (
|
||||||
|
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{liveBusy ? "Загрузка…" : liveLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Frame dense className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||||
|
{emptyMessage ? (
|
||||||
|
<div className="flex flex-1 items-center p-4">
|
||||||
|
<Alert variant="warning" className="w-full">
|
||||||
|
<TriangleAlertIcon />
|
||||||
|
<AlertTitle>Нет данных</AlertTitle>
|
||||||
|
<AlertDescription>{emptyMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full min-h-0 flex-1">
|
||||||
|
<div className="min-h-[min(52vh,22rem)]">
|
||||||
|
<CodeBlock code={code} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
{onLiveFetch && !isLive ? (
|
||||||
|
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
|
||||||
|
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||||
|
<SheetClose
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="ghost" className="shrink-0 px-3" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</SheetClose>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={() => downloadText(filename, code)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
Файл
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="default"
|
||||||
|
disabled={!canCopy}
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="min-w-28"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-4 text-success" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CodeExportSheet, downloadText }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CodeExportSheet, downloadText } from "./code-export-sheet"
|
||||||
|
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
|
||||||
|
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||||
|
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||||
|
export { kpiCols } from "./kpi-cols"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Shared grid column classes for hybrid KPI tiles.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return "grid-cols-1"
|
||||||
|
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
|
||||||
|
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
|
||||||
|
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||||
|
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
|
||||||
|
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { kpiCols } from "./kpi-cols"
|
||||||
|
|
||||||
|
export type KpiStatVariant = "default" | "warning" | "destructive"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
href?: string
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: "text-foreground",
|
||||||
|
warning: "text-warning",
|
||||||
|
destructive: "text-destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||||
|
return item.onClick ?? item.onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(item: KpiStatItem): boolean {
|
||||||
|
return Boolean(item.selected ?? item.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === "string") {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? "default"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.href || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
"relative isolate flex h-full min-w-0 flex-col",
|
||||||
|
clickable &&
|
||||||
|
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
|
||||||
|
selected && "ring-primary/30 bg-muted/30 ring-1",
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn("grid gap-2", kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
embedded?: boolean
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link href={item.href} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn("w-full", className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
|
if (item.id) return item.id
|
||||||
|
if (typeof item.label === "string") return item.label
|
||||||
|
return `kpi-${index}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState, type ReactNode } from "react"
|
||||||
|
import type { ServerStatus, ServerType } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { StatusDot } from "@/components/status-dot"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { IconTile } from "@/components/reui/icon-tile"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||||
|
|
||||||
|
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
|
||||||
|
export const ALL_SERVERS_ID = "all"
|
||||||
|
|
||||||
|
export interface ServerTileItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
country?: string
|
||||||
|
status?: ServerStatus
|
||||||
|
type?: ServerType
|
||||||
|
count?: number
|
||||||
|
enabled?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeVariant(
|
||||||
|
type: ServerType,
|
||||||
|
): "focus-light" | "info-light" | "success-light" {
|
||||||
|
if (type === "jump-host") return "focus-light"
|
||||||
|
if (type === "home-router") return "success-light"
|
||||||
|
return "info-light"
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadgeLabel(type: ServerType): string {
|
||||||
|
if (type === "jump-host") return "JH"
|
||||||
|
if (type === "home-router") return "HR"
|
||||||
|
return "EN"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTypeBadge({ type }: { type: ServerType }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
|
||||||
|
{typeBadgeLabel(type)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
||||||
|
if (!q) return true
|
||||||
|
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
|
||||||
|
return hay.includes(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTileRail({
|
||||||
|
items,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
allCount = 0,
|
||||||
|
showHeader = true,
|
||||||
|
showAll = true,
|
||||||
|
showCount = true,
|
||||||
|
showType = true,
|
||||||
|
headerRight,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: ServerTileItem[]
|
||||||
|
selectedId: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
allCount?: number
|
||||||
|
showHeader?: boolean
|
||||||
|
showAll?: boolean
|
||||||
|
showCount?: boolean
|
||||||
|
showType?: boolean
|
||||||
|
headerRight?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => items.filter((item) => matchesQuery(item, q)),
|
||||||
|
[items, q],
|
||||||
|
)
|
||||||
|
|
||||||
|
const showAllTile = showAll && !q
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
||||||
|
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||||
|
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
|
||||||
|
{showHeader ? (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Серверы
|
||||||
|
</FrameTitle>
|
||||||
|
{headerRight}
|
||||||
|
</div>
|
||||||
|
) : headerRight ? (
|
||||||
|
<div className="flex justify-end">{headerRight}</div>
|
||||||
|
) : null}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-3.5" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Поиск…"
|
||||||
|
aria-label="Поиск сервера"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</FrameHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
|
||||||
|
{showAllTile ? (
|
||||||
|
<ServerTileButton
|
||||||
|
selected={selectedId === ALL_SERVERS_ID}
|
||||||
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||||
|
title="Все серверы"
|
||||||
|
name="Все"
|
||||||
|
count={showCount ? allCount : undefined}
|
||||||
|
icon={
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
|
<LayersIcon className="text-muted-foreground" />
|
||||||
|
</IconTile>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<ServerTileButton
|
||||||
|
key={item.id}
|
||||||
|
selected={selectedId === item.id}
|
||||||
|
onSelect={() => onSelect(item.id)}
|
||||||
|
title={item.title ?? item.name}
|
||||||
|
name={item.name}
|
||||||
|
count={showCount ? item.count : undefined}
|
||||||
|
enabled={item.enabled}
|
||||||
|
selectable={item.selectable}
|
||||||
|
status={item.status}
|
||||||
|
type={showType ? item.type : undefined}
|
||||||
|
icon={
|
||||||
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||||
|
{item.country ? (
|
||||||
|
<Flag code={item.country} size={16} />
|
||||||
|
) : (
|
||||||
|
<ServerIcon className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</IconTile>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && !showAllTile ? (
|
||||||
|
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||||
|
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerTileButton({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
title,
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
enabled = true,
|
||||||
|
selectable = true,
|
||||||
|
status,
|
||||||
|
type,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
count?: number
|
||||||
|
enabled?: boolean
|
||||||
|
selectable?: boolean
|
||||||
|
status?: ServerStatus
|
||||||
|
type?: ServerType
|
||||||
|
icon: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
title={title}
|
||||||
|
disabled={!selectable}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
|
||||||
|
selected
|
||||||
|
? "bg-muted ring-1 ring-border"
|
||||||
|
: "hover:bg-muted/60",
|
||||||
|
!enabled && !selected && "opacity-40",
|
||||||
|
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
|
{status ? <StatusDot status={status} /> : null}
|
||||||
|
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
|
||||||
|
{type ? <ServerTypeBadge type={type} /> : null}
|
||||||
|
</span>
|
||||||
|
{count != null ? (
|
||||||
|
<Badge
|
||||||
|
variant={selected ? "secondary" : "outline"}
|
||||||
|
size="xs"
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServerTileRail, ServerTypeBadge }
|
||||||
@@ -9,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
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
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,
|
||||||
|
defaultServerId,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
servers: ServerOption[]
|
||||||
|
busy?: boolean
|
||||||
|
defaultServerId?: string
|
||||||
|
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
|
||||||
|
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
||||||
|
}, [open, defaultServerId])
|
||||||
|
|
||||||
|
const canSubmit = useMemo(() => {
|
||||||
|
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
||||||
|
}, [form.serverId, form.name, form.listenPort])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
||||||
|
<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 }
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"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: ".rsc", filename: "wg.rsc", code: "" },
|
||||||
|
{ id: "conf", label: ".conf", filename: "wg.conf", code: "" },
|
||||||
|
{
|
||||||
|
id: "peer",
|
||||||
|
label: "Peer",
|
||||||
|
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: ".rsc",
|
||||||
|
filename: `${iface.name}.rsc`,
|
||||||
|
code: liveContent?.rsc ?? localRsc,
|
||||||
|
isLive: Boolean(liveContent?.rsc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "conf",
|
||||||
|
label: ".conf",
|
||||||
|
filename: `${iface.name}.conf`,
|
||||||
|
code: liveContent?.conf ?? localConf,
|
||||||
|
isLive: Boolean(liveContent?.conf),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peer",
|
||||||
|
label: "Peer",
|
||||||
|
filename: `${iface.name}-peer.conf`,
|
||||||
|
code: liveContent?.peerConf ?? peerConf,
|
||||||
|
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
|
||||||
|
isLive: Boolean(liveContent?.peerConf),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [iface, liveContent, peerId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeExportSheet
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Экспорт WireGuard"
|
||||||
|
description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
|
||||||
|
formats={formats}
|
||||||
|
initialFormatId={initialTab}
|
||||||
|
liveBusy={liveBusy}
|
||||||
|
liveLabel="С роутера"
|
||||||
|
onLiveFetch={
|
||||||
|
onRequestLiveExport
|
||||||
|
? (formatId) =>
|
||||||
|
onRequestLiveExport(
|
||||||
|
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { WgExportSheet }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, 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,
|
||||||
|
defaultServerId,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
servers: ServerOption[]
|
||||||
|
busy?: boolean
|
||||||
|
defaultServerId?: string
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setServerId(defaultServerId ?? "")
|
||||||
|
}, [open, defaultServerId])
|
||||||
|
|
||||||
|
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) {
|
||||||
|
setServerId(defaultServerId ?? "")
|
||||||
|
} else {
|
||||||
|
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 }
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# CDN Manager + MikrotikManager + one Traefik (production).
|
||||||
|
#
|
||||||
|
# Hosts:
|
||||||
|
# https://cdn.shnt.top → cdnmanager:8080
|
||||||
|
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
|
||||||
|
#
|
||||||
|
# On server:
|
||||||
|
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||||
|
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||||
|
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
|
||||||
|
# # targets.json:
|
||||||
|
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||||
|
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
|
||||||
|
# docker login git.shx.one
|
||||||
|
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# DNS (Cloudflare DNS only, grey cloud):
|
||||||
|
# A/AAAA cdn.shnt.top → VPS
|
||||||
|
# A/AAAA mm.shnt.top → VPS
|
||||||
|
#
|
||||||
|
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
|
||||||
|
# on the same host ports while this stack is up.
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||||
|
container_name: cdn-mm-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||||
|
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||||
|
environment:
|
||||||
|
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||||
|
# Optional if DNS token lacks Zone:Read:
|
||||||
|
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||||
|
command:
|
||||||
|
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||||
|
- --api.dashboard=false
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.network=edge
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- traefik_letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# --- CDN Manager -----------------------------------------------------------
|
||||||
|
cdnmanager:
|
||||||
|
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
|
||||||
|
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: cdnmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: sqlite:/data/app.db
|
||||||
|
STATIC_DIR: /app/static
|
||||||
|
SERVER_PORT: "8080"
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||||
|
volumes:
|
||||||
|
- ./data/cdn:/data
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=edge
|
||||||
|
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
|
||||||
|
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||||
|
- traefik.http.routers.cdnmanager.tls=true
|
||||||
|
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# --- MikrotikManager -------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "8000"
|
||||||
|
DATABASE_PATH: /app/data/mikrotik.db
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
volumes:
|
||||||
|
- ./data/mm:/app/data
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- backend
|
||||||
|
labels:
|
||||||
|
mmapp.updater.managed: "true"
|
||||||
|
mmapp.updater.target: backend
|
||||||
|
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- frontend
|
||||||
|
edge: {}
|
||||||
|
labels:
|
||||||
|
- mmapp.updater.managed=true
|
||||||
|
- mmapp.updater.target=frontend
|
||||||
|
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=edge
|
||||||
|
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||||
|
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||||
|
- traefik.http.routers.mmapp.tls=true
|
||||||
|
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
updater:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
environment:
|
||||||
|
REGISTRY: git.shx.one
|
||||||
|
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||||
|
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||||
|
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||||
|
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||||
|
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./state:/state
|
||||||
|
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
traefik_letsencrypt:
|
||||||
|
name: cdn_mm_traefik_letsencrypt
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
name: edge
|
||||||
|
mmapp:
|
||||||
|
name: mmapp
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# MikrotikManager behind an existing CDNManager Traefik (no second Traefik).
|
||||||
|
#
|
||||||
|
# Prerequisite: CDNManager stack is up — network `cdnmanager` and container
|
||||||
|
# `cdnmanager-traefik` already publish :80/:443 and watch Docker labels.
|
||||||
|
#
|
||||||
|
# On server:
|
||||||
|
# mkdir -p /opt/mmapp/data /opt/mmapp/state /opt/mmapp/updater
|
||||||
|
# cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||||
|
# cp deploy/env.traefik.example /opt/mmapp/.env
|
||||||
|
# # set MM_DOMAIN / CORS_ORIGIN; CF_* / LETSENCRYPT_* not required here
|
||||||
|
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||||
|
# docker login git.shx.one
|
||||||
|
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# Equivalent plain CLI: deploy/run-beside-cdn-traefik.sh
|
||||||
|
#
|
||||||
|
# Traffic:
|
||||||
|
# Internet → CDNManager Traefik (:80/:443) → mmapp-frontend:3000 (network cdnmanager)
|
||||||
|
# └─ rewrite /api,/health → backend:8000 (network mmapp)
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "8000"
|
||||||
|
DATABASE_PATH: /app/data/mikrotik.db
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- backend
|
||||||
|
labels:
|
||||||
|
mmapp.updater.managed: "true"
|
||||||
|
mmapp.updater.target: backend
|
||||||
|
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- frontend
|
||||||
|
cdnmanager: {}
|
||||||
|
labels:
|
||||||
|
- mmapp.updater.managed=true
|
||||||
|
- mmapp.updater.target=frontend
|
||||||
|
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=cdnmanager
|
||||||
|
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||||
|
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||||
|
- traefik.http.routers.mmapp.tls=true
|
||||||
|
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
updater:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
environment:
|
||||||
|
REGISTRY: git.shx.one
|
||||||
|
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||||
|
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||||
|
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||||
|
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||||
|
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./state:/state
|
||||||
|
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
name: mmapp
|
||||||
|
cdnmanager:
|
||||||
|
external: true
|
||||||
|
name: cdnmanager
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# MikrotikManager + Traefik in one Compose stack (production).
|
||||||
|
# Docs: README.md (раздел «Прод-развёртывание Docker»)
|
||||||
|
#
|
||||||
|
# On server:
|
||||||
|
# mkdir -p /opt/mmapp/data /opt/mmapp/state
|
||||||
|
# cp deploy/docker-compose.traefik.yml /opt/mmapp/docker-compose.yml
|
||||||
|
# cp deploy/env.traefik.example /opt/mmapp/.env # fill secrets
|
||||||
|
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||||
|
# docker login git.shx.one
|
||||||
|
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# Traffic:
|
||||||
|
# Internet → :80/:443 (Traefik) → frontend:3000
|
||||||
|
# └─ rewrite /api,/health → backend:8000 (internal)
|
||||||
|
# Backend is not published on the host — only Traefik exposes 80/443.
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||||
|
container_name: mmapp-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||||
|
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||||
|
environment:
|
||||||
|
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||||
|
# Optional if DNS token lacks Zone:Read:
|
||||||
|
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||||
|
command:
|
||||||
|
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||||
|
- --api.dashboard=false
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.network=mmapp
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- traefik_letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
# No host ports — frontend reaches backend on the Compose network.
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "8000"
|
||||||
|
DATABASE_PATH: /app/data/mikrotik.db
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
labels:
|
||||||
|
mmapp.updater.managed: "true"
|
||||||
|
mmapp.updater.target: backend
|
||||||
|
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
# No host ports — only Traefik publishes 80/443.
|
||||||
|
environment:
|
||||||
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
labels:
|
||||||
|
- mmapp.updater.managed=true
|
||||||
|
- mmapp.updater.target=frontend
|
||||||
|
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=mmapp
|
||||||
|
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||||
|
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||||
|
- traefik.http.routers.mmapp.tls=true
|
||||||
|
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
updater:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
environment:
|
||||||
|
REGISTRY: git.shx.one
|
||||||
|
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||||
|
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||||
|
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||||
|
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||||
|
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./state:/state
|
||||||
|
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
traefik_letsencrypt:
|
||||||
|
name: mmapp_traefik_letsencrypt
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
name: mmapp
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Production .env for deploy/docker-compose.cdn-mm.yml
|
||||||
|
# (CDN Manager + MikrotikManager + one Traefik).
|
||||||
|
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
|
||||||
|
|
||||||
|
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
|
||||||
|
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
|
||||||
|
CF_DNS_API_TOKEN=
|
||||||
|
[email protected]
|
||||||
|
# TRAEFIK_IMAGE_TAG=v3.7
|
||||||
|
# TRAEFIK_HTTP_PORT=80
|
||||||
|
# TRAEFIK_HTTPS_PORT=443
|
||||||
|
# TRAEFIK_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- Public hosts ---
|
||||||
|
CDN_DOMAIN=cdn.shnt.top
|
||||||
|
MM_DOMAIN=mm.shnt.top
|
||||||
|
# Must match MM UI origin (https:// + MM_DOMAIN).
|
||||||
|
CORS_ORIGIN=https://mm.shnt.top
|
||||||
|
|
||||||
|
# --- Images ---
|
||||||
|
CDN_IMAGE_TAG=latest
|
||||||
|
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||||
|
MM_BACKEND_IMAGE_TAG=latest
|
||||||
|
MM_FRONTEND_IMAGE_TAG=latest
|
||||||
|
MM_UPDATER_IMAGE_TAG=latest
|
||||||
|
|
||||||
|
# --- CDN Manager ---
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Portal SSO — used by CDN Manager and MikrotikManager backend
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||||
|
AUTH_JWT_SECRET=
|
||||||
|
# Optional alias — CDN Manager also reads JWT_SECRET
|
||||||
|
JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
|
||||||
|
AUTH_AUDIT_INGEST_SECRET=
|
||||||
|
|
||||||
|
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# --- MikrotikManager updater (optional; private registry pull) ---
|
||||||
|
REGISTRY_USERNAME=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
|
# POLL_INTERVAL_SECONDS=300
|
||||||
|
# HEALTH_TIMEOUT_SECONDS=120
|
||||||
|
# STOP_TIMEOUT_SECONDS=30
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Production .env for MikrotikManager Traefik deploys. Do not commit.
|
||||||
|
# Use with:
|
||||||
|
# deploy/docker-compose.traefik.yml — own Traefik (standalone)
|
||||||
|
# deploy/docker-compose.traefik-cdn.yml — reuse CDNManager Traefik (network cdnmanager)
|
||||||
|
# deploy/run-beside-cdn-traefik.sh — plain docker CLI beside CDNManager
|
||||||
|
|
||||||
|
# --- Public host ---
|
||||||
|
MM_DOMAIN=mm.shnt.top
|
||||||
|
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
|
||||||
|
CORS_ORIGIN=https://mm.shnt.top
|
||||||
|
|
||||||
|
# --- Portal SSO (MM backend) ---
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
|
||||||
|
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
|
||||||
|
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
|
||||||
|
CF_DNS_API_TOKEN=
|
||||||
|
[email protected]
|
||||||
|
# TRAEFIK_IMAGE_TAG=v3.7
|
||||||
|
# TRAEFIK_HTTP_PORT=80
|
||||||
|
# TRAEFIK_HTTPS_PORT=443
|
||||||
|
# TRAEFIK_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- Images ---
|
||||||
|
MM_BACKEND_IMAGE_TAG=latest
|
||||||
|
MM_FRONTEND_IMAGE_TAG=latest
|
||||||
|
MM_UPDATER_IMAGE_TAG=latest
|
||||||
|
|
||||||
|
# --- Updater (optional; needed for private registry pull) ---
|
||||||
|
REGISTRY_USERNAME=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
|
# POLL_INTERVAL_SECONDS=300
|
||||||
|
# HEALTH_TIMEOUT_SECONDS=120
|
||||||
|
# STOP_TIMEOUT_SECONDS=30
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run MikrotikManager beside an already-running CDNManager Traefik stack.
|
||||||
|
# Does NOT start a second Traefik — attaches frontend to network `cdnmanager`.
|
||||||
|
#
|
||||||
|
# Usage (on the VPS):
|
||||||
|
# curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||||
|
# https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||||
|
# chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||||
|
# sudo MM_DOMAIN=mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||||
|
#
|
||||||
|
# Or copy this file to the server and run it.
|
||||||
|
#
|
||||||
|
# Env overrides:
|
||||||
|
# MM_DOMAIN=mm.shnt.top
|
||||||
|
# CORS_ORIGIN=https://mm.shnt.top
|
||||||
|
# MM_ROOT=/opt/mmapp
|
||||||
|
# MM_BACKEND_IMAGE_TAG=latest
|
||||||
|
# MM_FRONTEND_IMAGE_TAG=latest
|
||||||
|
# MM_UPDATER_IMAGE_TAG=latest
|
||||||
|
# REGISTRY_USERNAME=… REGISTRY_PASSWORD=… # optional private pull
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MM_DOMAIN="${MM_DOMAIN:-mm.shnt.top}"
|
||||||
|
CORS_ORIGIN="${CORS_ORIGIN:-https://${MM_DOMAIN}}"
|
||||||
|
MM_ROOT="${MM_ROOT:-/opt/mmapp}"
|
||||||
|
MM_BACKEND_IMAGE_TAG="${MM_BACKEND_IMAGE_TAG:-latest}"
|
||||||
|
MM_FRONTEND_IMAGE_TAG="${MM_FRONTEND_IMAGE_TAG:-latest}"
|
||||||
|
MM_UPDATER_IMAGE_TAG="${MM_UPDATER_IMAGE_TAG:-latest}"
|
||||||
|
REGISTRY="${REGISTRY:-git.shx.one}"
|
||||||
|
|
||||||
|
BACKEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG}"
|
||||||
|
FRONTEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG}"
|
||||||
|
UPDATER_IMAGE="${REGISTRY}/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG}"
|
||||||
|
|
||||||
|
echo "==> Check CDNManager Traefik network"
|
||||||
|
if ! docker network inspect cdnmanager >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Docker network 'cdnmanager' not found." >&2
|
||||||
|
echo "Start CDNManager Traefik stack first (deploy/docker-compose.traefik.yml)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker inspect cdnmanager-traefik >/dev/null 2>&1; then
|
||||||
|
echo "WARN: container 'cdnmanager-traefik' not found — labels may not be routed." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Prepare dirs under ${MM_ROOT}"
|
||||||
|
mkdir -p "${MM_ROOT}/data" "${MM_ROOT}/state" "${MM_ROOT}/updater"
|
||||||
|
|
||||||
|
TARGETS="${MM_ROOT}/updater/targets.json"
|
||||||
|
if [[ ! -f "${TARGETS}" ]]; then
|
||||||
|
cat >"${TARGETS}" <<'EOF'
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"id": "backend",
|
||||||
|
"container_name": "mmapp-backend",
|
||||||
|
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
|
||||||
|
"health": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://backend:8000/health",
|
||||||
|
"expect_status": 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frontend",
|
||||||
|
"container_name": "mmapp-frontend",
|
||||||
|
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
|
||||||
|
"health": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://frontend:3000/dashboard",
|
||||||
|
"expect_status": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo " wrote ${TARGETS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Ensure internal network mmapp"
|
||||||
|
docker network inspect mmapp >/dev/null 2>&1 || docker network create mmapp >/dev/null
|
||||||
|
|
||||||
|
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||||
|
echo "==> docker login ${REGISTRY}"
|
||||||
|
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USERNAME}" --password-stdin
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pull images"
|
||||||
|
docker pull "${BACKEND_IMAGE}"
|
||||||
|
docker pull "${FRONTEND_IMAGE}"
|
||||||
|
docker pull "${UPDATER_IMAGE}"
|
||||||
|
|
||||||
|
stop_rm() {
|
||||||
|
local name="$1"
|
||||||
|
if docker inspect "${name}" >/dev/null 2>&1; then
|
||||||
|
docker stop "${name}" >/dev/null || true
|
||||||
|
docker rm "${name}" >/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Recreate mmapp-backend"
|
||||||
|
stop_rm mmapp-backend
|
||||||
|
docker run -d \
|
||||||
|
--name mmapp-backend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network mmapp \
|
||||||
|
--network-alias backend \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
-e PORT=8000 \
|
||||||
|
-e DATABASE_PATH=/app/data/mikrotik.db \
|
||||||
|
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
|
||||||
|
-e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
|
||||||
|
-e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
|
||||||
|
-e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
|
||||||
|
-e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
|
||||||
|
-v "${MM_ROOT}/data:/app/data" \
|
||||||
|
--label mmapp.updater.managed=true \
|
||||||
|
--label mmapp.updater.target=backend \
|
||||||
|
--label "mmapp.updater.image=${BACKEND_IMAGE}" \
|
||||||
|
--health-cmd="node -e \"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||||
|
--health-interval=30s \
|
||||||
|
--health-timeout=5s \
|
||||||
|
--health-retries=3 \
|
||||||
|
--health-start-period=15s \
|
||||||
|
--log-driver json-file \
|
||||||
|
--log-opt max-size=10m \
|
||||||
|
--log-opt max-file=3 \
|
||||||
|
"${BACKEND_IMAGE}"
|
||||||
|
|
||||||
|
echo "==> Recreate mmapp-frontend (mmapp + cdnmanager)"
|
||||||
|
stop_rm mmapp-frontend
|
||||||
|
docker run -d \
|
||||||
|
--name mmapp-frontend \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network mmapp \
|
||||||
|
--network-alias frontend \
|
||||||
|
-e BACKEND_INTERNAL_URL=http://backend:8000 \
|
||||||
|
--label mmapp.updater.managed=true \
|
||||||
|
--label mmapp.updater.target=frontend \
|
||||||
|
--label "mmapp.updater.image=${FRONTEND_IMAGE}" \
|
||||||
|
--label traefik.enable=true \
|
||||||
|
--label traefik.docker.network=cdnmanager \
|
||||||
|
--label "traefik.http.routers.mmapp.rule=Host(\`${MM_DOMAIN}\`)" \
|
||||||
|
--label traefik.http.routers.mmapp.entrypoints=websecure \
|
||||||
|
--label traefik.http.routers.mmapp.tls=true \
|
||||||
|
--label traefik.http.routers.mmapp.tls.certresolver=letsencrypt \
|
||||||
|
--label traefik.http.services.mmapp.loadbalancer.server.port=3000 \
|
||||||
|
--health-cmd="node -e \"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||||
|
--health-interval=30s \
|
||||||
|
--health-timeout=5s \
|
||||||
|
--health-retries=3 \
|
||||||
|
--health-start-period=25s \
|
||||||
|
--log-driver json-file \
|
||||||
|
--log-opt max-size=10m \
|
||||||
|
--log-opt max-file=3 \
|
||||||
|
"${FRONTEND_IMAGE}"
|
||||||
|
|
||||||
|
docker network connect cdnmanager mmapp-frontend
|
||||||
|
|
||||||
|
echo "==> Recreate mmapp-updater"
|
||||||
|
stop_rm mmapp-updater
|
||||||
|
docker run -d \
|
||||||
|
--name mmapp-updater \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network mmapp \
|
||||||
|
-e "REGISTRY=${REGISTRY}" \
|
||||||
|
-e "REGISTRY_USERNAME=${REGISTRY_USERNAME:-}" \
|
||||||
|
-e "REGISTRY_PASSWORD=${REGISTRY_PASSWORD:-}" \
|
||||||
|
-e "POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-300}" \
|
||||||
|
-e "HEALTH_TIMEOUT_SECONDS=${HEALTH_TIMEOUT_SECONDS:-120}" \
|
||||||
|
-e "STOP_TIMEOUT_SECONDS=${STOP_TIMEOUT_SECONDS:-30}" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "${MM_ROOT}/state:/state" \
|
||||||
|
-v "${TARGETS}:/etc/updater/targets.json:ro" \
|
||||||
|
--log-driver json-file \
|
||||||
|
--log-opt max-size=10m \
|
||||||
|
--log-opt max-file=3 \
|
||||||
|
"${UPDATER_IMAGE}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "OK. UI: https://${MM_DOMAIN}"
|
||||||
|
echo "DNS: A/AAAA for ${MM_DOMAIN} → this VPS, Cloudflare proxy OFF (DNS only)."
|
||||||
|
echo "Check: curl -fsS https://${MM_DOMAIN}/health"
|
||||||
|
echo " docker ps --filter name=mmapp-"
|
||||||
|
echo " docker network inspect cdnmanager --format '{{range .Containers}}{{.Name}} {{end}}'"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Интеграция auth-portal ↔ MikrotikManager
|
||||||
|
|
||||||
|
App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md).
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → MikrotikManager UI (нет token)
|
||||||
|
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
|
||||||
|
→ login
|
||||||
|
→ redirect return_to#access_token=…
|
||||||
|
→ /auth/callback сохраняет token (localStorage: mmapp_token)
|
||||||
|
→ API Authorization: Bearer <JWT>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | UI / API |
|
||||||
|
|------------|----------|
|
||||||
|
| `mm:dashboard:read` | `/dashboard`, sidebar-counts |
|
||||||
|
| `mm:servers:read` / `write` | `/servers` |
|
||||||
|
| `mm:filters:read` / `write` | filters / GRE |
|
||||||
|
| `mm:bgp:read` / `write` | BGP |
|
||||||
|
| `mm:uptime:read` / `write` | `/uptime` |
|
||||||
|
| `mm:traffic:read` / `write` | `/traffic` |
|
||||||
|
| `mm:alerts:read` / `write` | `/alerts` |
|
||||||
|
| `mm:backups:read` / `write` | `/backups` |
|
||||||
|
| `mm:certificates:read` / `write` | certificates |
|
||||||
|
| `mm:network:read` / `write` | network / OSPF |
|
||||||
|
| `mm:settings:admin` | `/settings`, system DB, scheduler |
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
```env
|
||||||
|
# backend
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
CORS_ORIGIN=https://mm.shnt.top
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
# frontend build (Docker)
|
||||||
|
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
```
|
||||||
|
|
||||||
|
UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`.
|
||||||
|
|
||||||
|
В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
|
||||||
|
2. Origin MM в `RETURN_TO_ALLOWLIST` портала
|
||||||
|
3. Пользователю выдан app `mm`
|
||||||
|
4. `AUTH_REQUIRED=true` на backend
|
||||||
|
5. Logout → `{AUTH_PORTAL_URL}/logout`
|
||||||
|
|
||||||
|
Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose.
|
||||||
@@ -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}`.
|
||||||
+46
-19
@@ -8,6 +8,21 @@ import {
|
|||||||
parseAppSwitcherConfig,
|
parseAppSwitcherConfig,
|
||||||
type AppSwitcherConfig,
|
type AppSwitcherConfig,
|
||||||
} from "@/lib/app-switcher-config"
|
} from "@/lib/app-switcher-config"
|
||||||
|
import {
|
||||||
|
ensureAuthConfig,
|
||||||
|
getAuthConfigSync,
|
||||||
|
getClaims,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims?.apps?.length) return config
|
||||||
|
const allowed = new Set(claims.apps)
|
||||||
|
const apps = config.apps.filter(
|
||||||
|
(a) => a.id === "mm" || allowed.has(a.id),
|
||||||
|
)
|
||||||
|
return { ...config, apps: apps.length > 0 ? apps : config.apps }
|
||||||
|
}
|
||||||
|
|
||||||
export function useAppSwitcherConfig(): {
|
export function useAppSwitcherConfig(): {
|
||||||
config: AppSwitcherConfig
|
config: AppSwitcherConfig
|
||||||
@@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): {
|
|||||||
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
|
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
|
||||||
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
|
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
|
||||||
)
|
)
|
||||||
const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const base = authPortalBaseUrl()
|
|
||||||
if (!base) {
|
|
||||||
setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setIsLoading(true)
|
|
||||||
fetch(`${base}/api/v1/app-switcher`)
|
void (async () => {
|
||||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
await ensureAuthConfig()
|
||||||
.then((raw: unknown) => {
|
if (cancelled) return
|
||||||
|
|
||||||
|
const portal =
|
||||||
|
getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
|
||||||
|
if (!portal) {
|
||||||
|
setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${portal}/api/v1/app-switcher`)
|
||||||
|
if (!res.ok) throw new Error("switcher fetch failed")
|
||||||
|
const raw: unknown = await res.json()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const parsed = parseAppSwitcherConfig(raw)
|
const parsed = parseAppSwitcherConfig(raw)
|
||||||
setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
|
setConfig(
|
||||||
})
|
filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
|
||||||
.catch(() => {
|
)
|
||||||
if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
} catch {
|
||||||
})
|
if (!cancelled) {
|
||||||
.finally(() => {
|
setConfig(
|
||||||
|
filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
if (!cancelled) setIsLoading(false)
|
if (!cancelled) setIsLoading(false)
|
||||||
})
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
/** Portal JWT storage + claims helpers for MikrotikManager. */
|
||||||
|
|
||||||
|
const TOKEN_KEY = "mmapp_token"
|
||||||
|
const HANDOFF_KEY = "mmapp_auth_401_handoff"
|
||||||
|
const HANDOFF_AT_KEY = "mmapp_portal_handoff_at"
|
||||||
|
const HANDOFF_COOLDOWN_MS = 12_000
|
||||||
|
|
||||||
|
export type AccessClaims = {
|
||||||
|
sub: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
apps: string[]
|
||||||
|
permissions: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeAuthConfig = {
|
||||||
|
required: boolean
|
||||||
|
portalUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||||
|
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||||
|
|
||||||
|
function envPortalUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175"
|
||||||
|
).replace(/\/$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function envAuthEnabled(): boolean {
|
||||||
|
const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase()
|
||||||
|
return v === "true" || v === "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */
|
||||||
|
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||||
|
if (runtimeConfig) return runtimeConfig
|
||||||
|
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||||
|
|
||||||
|
runtimeConfigPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/config")
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
required?: boolean
|
||||||
|
portal_url?: string
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: Boolean(data.required) || envAuthEnabled(),
|
||||||
|
portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* use env defaults */
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: envAuthEnabled(),
|
||||||
|
portalUrl: envPortalUrl(),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
})().finally(() => {
|
||||||
|
runtimeConfigPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return runtimeConfigPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthEnabled(): boolean {
|
||||||
|
if (runtimeConfig) return runtimeConfig.required
|
||||||
|
return envAuthEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authPortalUrl(): string {
|
||||||
|
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||||
|
return envPortalUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPortalHandoffCoolingDown(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||||
|
if (!raw) return false
|
||||||
|
const at = Number(raw)
|
||||||
|
if (!Number.isFinite(at)) return false
|
||||||
|
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPortalHandoff(): void {
|
||||||
|
sessionStorage.setItem(HANDOFF_KEY, "1")
|
||||||
|
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPortalHandoffFlag(): void {
|
||||||
|
sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPortalHandoff(): void {
|
||||||
|
sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||||
|
if (isPortalHandoffCoolingDown()) {
|
||||||
|
clearToken()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
markPortalHandoff()
|
||||||
|
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||||
|
const url = new URL(authPortalUrl())
|
||||||
|
url.searchParams.set("return_to", callback)
|
||||||
|
window.location.assign(url.toString())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToPortalLoginInteractive(): void {
|
||||||
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
window.location.assign(authPortalUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToPortalLogout(): void {
|
||||||
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
window.location.assign(`${authPortalUrl()}/logout`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHashToken(hash: string): {
|
||||||
|
accessToken: string | null
|
||||||
|
expiresAt: string | null
|
||||||
|
} {
|
||||||
|
const raw = hash.startsWith("#") ? hash.slice(1) : hash
|
||||||
|
const params = new URLSearchParams(raw)
|
||||||
|
return {
|
||||||
|
accessToken: params.get("access_token"),
|
||||||
|
expiresAt: params.get("expires_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeClaims(token: string): AccessClaims | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".")
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/"))
|
||||||
|
const payload = JSON.parse(json) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
sub: String(payload.sub ?? ""),
|
||||||
|
email: String(payload.email ?? ""),
|
||||||
|
name: String(payload.name ?? ""),
|
||||||
|
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||||
|
permissions: Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: [],
|
||||||
|
is_admin: Boolean(payload.is_admin),
|
||||||
|
iss: payload.iss ? String(payload.iss) : undefined,
|
||||||
|
exp: typeof payload.exp === "number" ? payload.exp : undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaims(): AccessClaims | null {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return null
|
||||||
|
const claims = decodeClaims(token)
|
||||||
|
if (!claims) return null
|
||||||
|
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||||
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true
|
||||||
|
const parts = required.split(":")
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
const [app, section, action] = parts
|
||||||
|
if (action === "read") {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === "write") {
|
||||||
|
return granted.includes(`${app}:${section}:admin`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function can(required: string): boolean {
|
||||||
|
if (!isAuthEnabled()) return true
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) return false
|
||||||
|
if (!claims.apps.includes("mm")) return false
|
||||||
|
return hasPermission(claims.permissions, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function permissionForPath(pathname: string): string | null {
|
||||||
|
if (pathname === "/" || pathname.startsWith("/dashboard")) {
|
||||||
|
return "mm:dashboard:read"
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/servers")) return "mm:servers:read"
|
||||||
|
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
|
||||||
|
return "mm:filters:read"
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
|
||||||
|
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
|
||||||
|
if (pathname.startsWith("/traffic")) return "mm:traffic:read"
|
||||||
|
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
|
||||||
|
if (pathname.startsWith("/backups")) return "mm:backups:read"
|
||||||
|
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/network") ||
|
||||||
|
pathname.startsWith("/ospf") ||
|
||||||
|
pathname.startsWith("/route-optimizer")
|
||||||
|
) {
|
||||||
|
return "mm:network:read"
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/settings")) return "mm:settings:admin"
|
||||||
|
return "mm:dashboard:read"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstAllowedPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
"/dashboard",
|
||||||
|
"/servers",
|
||||||
|
"/filters",
|
||||||
|
"/uptime",
|
||||||
|
"/traffic",
|
||||||
|
"/alerts",
|
||||||
|
"/backups",
|
||||||
|
"/settings",
|
||||||
|
]
|
||||||
|
for (const path of candidates) {
|
||||||
|
const perm = permissionForPath(path)
|
||||||
|
if (!perm || can(perm)) return path
|
||||||
|
}
|
||||||
|
return "/access-denied"
|
||||||
|
}
|
||||||
+15
-1
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
|
|||||||
uptimeSpeedProbes: number
|
uptimeSpeedProbes: number
|
||||||
monitoringItems: number
|
monitoringItems: number
|
||||||
recursiveRoutes: number
|
recursiveRoutes: number
|
||||||
|
certificates?: number
|
||||||
|
wireguard?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
Generated
+245
-4
@@ -49,6 +49,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.2.2",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"pino-pretty": "^13.1.3",
|
"fastify-plugin": "^5.1.0",
|
||||||
"undici": "^8.1.0",
|
"undici": "^8.1.0",
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
},
|
},
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"jose": "^6.2.11",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
@@ -2018,6 +2021,45 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/jwt": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/error": "^4.2.0",
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fast-jwt": "^6.2.4",
|
||||||
|
"fastify-plugin": "^6.0.0",
|
||||||
|
"steed": "^1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/jwt/node_modules/fastify-plugin": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/merge-json-schemas": {
|
"node_modules/@fastify/merge-json-schemas": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||||
@@ -2814,6 +2856,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mmapp/contracts": {
|
"node_modules/@mmapp/contracts": {
|
||||||
"resolved": "packages/contracts",
|
"resolved": "packages/contracts",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -3900,6 +3951,70 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.1.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||||
@@ -5077,6 +5192,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asn1js": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.0.10",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
@@ -5275,6 +5402,12 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
@@ -5672,6 +5805,7 @@
|
|||||||
"version": "2.0.20",
|
"version": "2.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
@@ -5898,6 +6032,7 @@
|
|||||||
"version": "4.6.3",
|
"version": "4.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "*"
|
||||||
@@ -6281,6 +6416,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eciesjs": {
|
"node_modules/eciesjs": {
|
||||||
"version": "0.4.18",
|
"version": "0.4.18",
|
||||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz",
|
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz",
|
||||||
@@ -7159,6 +7303,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||||
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-decode-uri-component": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
@@ -7256,6 +7401,22 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-jwt": {
|
||||||
|
"version": "6.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz",
|
||||||
|
"integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"asn1.js": "^5.4.1",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"mnemonist": "^0.40.0",
|
||||||
|
"safe-regex2": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||||
@@ -7276,6 +7437,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-string-truncated-width": {
|
"node_modules/fast-string-truncated-width": {
|
||||||
@@ -7318,6 +7480,18 @@
|
|||||||
"fast-string-width": "^3.0.2"
|
"fast-string-width": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fastfall": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"reusify": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.8.5",
|
"version": "5.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
|
||||||
@@ -7379,6 +7553,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fastparallel": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"reusify": "^1.0.4",
|
||||||
|
"xtend": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -7388,6 +7572,16 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fastseries": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"reusify": "^1.0.0",
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fetch-blob": {
|
"node_modules/fetch-blob": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
@@ -8040,6 +8234,7 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
@@ -8839,9 +9034,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz",
|
||||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
"integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -8851,6 +9046,7 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -9596,6 +9792,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -9624,6 +9826,15 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mnemonist": {
|
||||||
|
"version": "0.40.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
|
||||||
|
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"obliterator": "^2.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -10103,6 +10314,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obliterator": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
@@ -10426,6 +10643,7 @@
|
|||||||
"version": "13.1.3",
|
"version": "13.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"colorette": "^2.0.7",
|
"colorette": "^2.0.7",
|
||||||
@@ -10450,6 +10668,7 @@
|
|||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
@@ -11792,6 +12011,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/steed": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastfall": "^1.5.0",
|
||||||
|
"fastparallel": "^2.2.0",
|
||||||
|
"fastq": "^1.3.0",
|
||||||
|
"fastseries": "^1.7.0",
|
||||||
|
"reusify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -13458,6 +13690,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { configuredBackendUrl } from "@/lib/backend-url"
|
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||||
|
import {
|
||||||
|
getToken,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
export class ApiClientError extends Error {
|
export class ApiClientError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -15,27 +21,73 @@ 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 res = await fetch(resolveRequestUrl(baseUrl, path), {
|
const headers = withAuthHeaders(init?.headers)
|
||||||
|
if (hasBody && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(resolveApiUrl(baseUrl, path), {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers,
|
||||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) handleUnauthorized()
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
|
|
||||||
const payload = await res.json().catch(() => undefined)
|
const payload = await res.json().catch(() => undefined)
|
||||||
@@ -52,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user