commit bf3d50e1ff2ee3b5c9268991a255c3b49ce6378e
Author: Denozordec
Date: Tue Nov 3 03:05:22 2026 +0700
feat(mcp): подготовить сервер и доступ к приёмке
diff --git a/.ci/release/release-manifest.json b/.ci/release/release-manifest.json
new file mode 100644
index 00000000..65565bdb
--- /dev/null
+++ b/.ci/release/release-manifest.json
@@ -0,0 +1,50 @@
+{
+ "version": "1.3.4",
+ "tag": "v1.3.4",
+ "publishedAt": "2026-05-12T13:50:06.198Z",
+ "releaseUrl": "",
+ "commits": [
+ {
+ "sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
+ "shortSha": "3ce4179",
+ "subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
+ "author": "Denozordec",
+ "type": "chore"
+ },
+ {
+ "sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
+ "shortSha": "1a47905",
+ "subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
+ "author": "Denozordec",
+ "type": "fix"
+ },
+ {
+ "sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
+ "shortSha": "b51d979",
+ "subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
+ "author": "Denozordec",
+ "type": "fix"
+ },
+ {
+ "sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
+ "shortSha": "bc01da1",
+ "subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
+ "author": "Denozordec",
+ "type": "fix"
+ },
+ {
+ "sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
+ "shortSha": "500a543",
+ "subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
+ "author": "Denozordec",
+ "type": "fix"
+ },
+ {
+ "sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
+ "shortSha": "60024fa",
+ "subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
+ "author": "Denozordec",
+ "type": "fix"
+ }
+ ]
+}
diff --git a/.ci/release/release_notes.md b/.ci/release/release_notes.md
new file mode 100644
index 00000000..df7ddb5a
--- /dev/null
+++ b/.ci/release/release_notes.md
@@ -0,0 +1,18 @@
+# v1.3.4
+
+Версия **1.3.4**.
+
+## fix
+
+- fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов (`1a47905`, Denozordec)
+- fix(backend): улучшить обработку хранилищ доверия для сертификатов (`b51d979`, Denozordec)
+- fix(backend): добавить функции для работы с IP-адресами и DNS-записями (`bc01da1`, Denozordec)
+- fix(backend): улучшить уникальность идентификаторов сертификатов (`500a543`, Denozordec)
+- fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов (`60024fa`, Denozordec)
+- fix(ui): добавить новые компоненты для управления сертификатами (`53ea0e7`, Denozordec)
+- fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки (`6c8ca17`, Denozordec)
+
+## chore
+
+- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
+- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, Denozordec)
diff --git a/.ci/scripts/compute-release.mjs b/.ci/scripts/compute-release.mjs
new file mode 100644
index 00000000..535b3c5b
--- /dev/null
+++ b/.ci/scripts/compute-release.mjs
@@ -0,0 +1,293 @@
+#!/usr/bin/env node
+import { execFileSync } from "node:child_process"
+import { mkdirSync, writeFileSync } from "node:fs"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const repoRoot = join(__dirname, "..", "..")
+const baseVersion = process.env.BASE_VERSION ?? "1.0.0"
+const outputDir = process.env.RELEASE_OUTPUT_DIR
+ ? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
+ : join(repoRoot, ".ci", "release")
+const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
+const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
+
+const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
+const CONVENTIONAL_RE =
+ /^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
+const MINOR_TYPES = new Set(["feat"])
+const PATCH_TYPES = new Set([
+ "fix",
+ "chore",
+ "docs",
+ "refactor",
+ "style",
+ "test",
+ "build",
+ "ci",
+])
+
+function git(...args) {
+ return execFileSync("git", args, {
+ cwd: repoRoot,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ }).trim()
+}
+
+function parseSemver(version) {
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
+ if (!match) {
+ throw new Error(`Invalid semver: ${version}`)
+ }
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ }
+}
+
+function formatSemver(parts) {
+ return `${parts.major}.${parts.minor}.${parts.patch}`
+}
+
+function bumpVersion(version, bump) {
+ const parts = parseSemver(version)
+ if (bump === "major") {
+ parts.major += 1
+ parts.minor = 0
+ parts.patch = 0
+ return formatSemver(parts)
+ }
+ if (bump === "minor") {
+ parts.minor += 1
+ parts.patch = 0
+ return formatSemver(parts)
+ }
+ parts.patch += 1
+ return formatSemver(parts)
+}
+
+function parseCommit(subject, body = "") {
+ const match = CONVENTIONAL_RE.exec(subject.trim())
+ const breaking =
+ /BREAKING[ -]CHANGE:/i.test(body) ||
+ (match !== null && /^[^:]+!:\s/.test(subject.trim()))
+
+ if (!match) {
+ return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
+ }
+
+ const type = match[1].toLowerCase()
+ const scope = match[2]?.slice(1, -1) ?? null
+ const description = match[3].trim()
+ let bump = "patch"
+
+ if (breaking) {
+ bump = "major"
+ } else if (MINOR_TYPES.has(type)) {
+ bump = "minor"
+ } else if (PATCH_TYPES.has(type)) {
+ bump = "patch"
+ }
+
+ return { type, scope, description, bump }
+}
+
+function getAllTags() {
+ try {
+ return git("tag", "--list", "v*.*.*", "--sort=-v:refname")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ } catch {
+ return []
+ }
+}
+
+function getPreviousTag(tag) {
+ const tags = getAllTags()
+ const index = tags.indexOf(tag)
+ return index >= 0 && index + 1 < tags.length ? tags[index + 1] : null
+}
+
+function getTagPublishedAt(tag) {
+ try {
+ return git("log", "-1", "--format=%aI", tag)
+ } catch {
+ return null
+ }
+}
+
+function toManifestCommits(commits) {
+ return commits.map((commit) => ({
+ sha: commit.sha,
+ shortSha: commit.shortSha,
+ subject: commit.subject,
+ author: commit.author,
+ type: commit.type,
+ }))
+}
+
+function writeManifest(manifest) {
+ mkdirSync(outputDir, { recursive: true })
+ writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
+}
+
+function getLatestTag() {
+ return getAllTags()[0] ?? null
+}
+
+function getCommitsSince(tag) {
+ const range = tag ? `${tag}..HEAD` : "HEAD"
+ const raw = git(
+ "log",
+ range,
+ "--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
+ )
+ if (!raw) return []
+
+ return parseCommitLog(raw)
+}
+
+function getRecentCommits(limit = MANIFEST_COMMIT_LIMIT) {
+ const raw = git(
+ "log",
+ "-n",
+ String(limit),
+ "HEAD",
+ "--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
+ )
+ if (!raw) return []
+
+ return parseCommitLog(raw)
+}
+
+function parseCommitLog(raw) {
+ return raw
+ .split("\x1e")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const [sha, shortSha, author, subject, body] = entry.split("\x1f")
+ const parsed = parseCommit(subject, body)
+ return {
+ sha,
+ shortSha,
+ author,
+ subject,
+ type: parsed.type,
+ scope: parsed.scope,
+ bump: parsed.bump,
+ }
+ })
+}
+
+function groupCommits(commits) {
+ const groups = new Map()
+ for (const commit of commits) {
+ const key = commit.type
+ if (!groups.has(key)) groups.set(key, [])
+ groups.get(key).push(commit)
+ }
+ return groups
+}
+
+function renderReleaseNotes({ tag, version, commits }) {
+ const lines = [`# ${tag}`, "", `Версия **${version}**.`, ""]
+ const groups = groupCommits(commits)
+ const order = ["feat", "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", "other"]
+
+ for (const type of order) {
+ const items = groups.get(type)
+ if (!items?.length) continue
+ lines.push(`## ${type}`, "")
+ for (const item of items) {
+ lines.push(`- ${item.subject} (\`${item.shortSha}\`, ${item.author})`)
+ }
+ lines.push("")
+ }
+
+ return `${lines.join("\n").trim()}\n`
+}
+
+function buildReleaseUrl(tag) {
+ if (!repository) return ""
+ const [owner, repo] = repository.split("/")
+ if (!owner || !repo) return ""
+ return `${serverUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}`
+}
+
+function writeGithubOutput(values) {
+ const outputFile = process.env.GITHUB_OUTPUT
+ if (!outputFile) return
+ const lines = Object.entries(values)
+ .map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "%0A")}`)
+ .join("\n")
+ writeFileSync(outputFile, `${lines}\n`, { encoding: "utf8", flag: "a" })
+}
+
+function main() {
+ const latestTag = getLatestTag()
+ const baseFromTag = latestTag?.replace(/^v/, "") ?? baseVersion
+ const commits = getCommitsSince(latestTag)
+
+ if (commits.length === 0) {
+ const deployTag = latestTag ?? `v${baseFromTag}`
+ const deployVersion = baseFromTag
+ const displayCommits = getRecentCommits()
+ const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
+
+ mkdirSync(outputDir, { recursive: true })
+ writeFileSync(join(outputDir, "release_notes.md"), "", "utf8")
+ writeManifest({
+ version: deployVersion,
+ tag: deployTag,
+ publishedAt,
+ releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "",
+ commits: toManifestCommits(displayCommits),
+ })
+
+ writeGithubOutput({
+ should_release: "false",
+ version: deployVersion,
+ tag: deployTag,
+ bump: "none",
+ release_url: latestTag ? buildReleaseUrl(latestTag) : "",
+ })
+ return
+ }
+
+ const bump = commits.some((commit) => commit.bump === "major")
+ ? "major"
+ : commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
+ const version = bumpVersion(baseFromTag, bump)
+ const tag = `v${version}`
+ const publishedAt = new Date().toISOString()
+ const releaseUrl = buildReleaseUrl(tag)
+ const releaseNotes = renderReleaseNotes({ tag, version, commits })
+ const manifest = {
+ version,
+ tag,
+ publishedAt,
+ releaseUrl,
+ commits: toManifestCommits(getRecentCommits()),
+ }
+
+ mkdirSync(outputDir, { recursive: true })
+ writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8")
+ writeManifest(manifest)
+
+ writeGithubOutput({
+ should_release: "true",
+ version,
+ tag,
+ bump,
+ release_url: releaseUrl,
+ })
+
+ process.stdout.write(`${tag}\n`)
+}
+
+main()
diff --git a/.ci/scripts/compute-release.test.mjs b/.ci/scripts/compute-release.test.mjs
new file mode 100644
index 00000000..92b5dc59
--- /dev/null
+++ b/.ci/scripts/compute-release.test.mjs
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict"
+import { execFileSync } from "node:child_process"
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { basename, dirname, join, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+import test from "node:test"
+
+const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
+
+const cases = [
+ { name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
+ { name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
+ { name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
+ { name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
+ { name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
+ { name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
+ { name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
+ { name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
+ { name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
+ { name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
+]
+
+for (const scenario of cases) test(scenario.name, () => {
+ const parent = resolve(tmpdir())
+ const root = mkdtempSync(join(parent, "mm-release-"))
+ const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
+ try {
+ mkdirSync(join(root, ".ci/scripts"), { recursive: true })
+ copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
+ git("init", "--initial-branch=main")
+ git("config", "user.name", "Release Test")
+ git("config", "user.email", "release@example.invalid")
+ git("config", "core.hooksPath", ".git/hooks")
+ writeFileSync(join(root, "fixture"), "baseline")
+ git("add", "fixture")
+ git("commit", "-m", "chore(test): создать исходную базу")
+ git("tag", `v${scenario.base ?? "1.47.6"}`)
+ for (const [index, [subject, body]] of scenario.commits.entries()) {
+ writeFileSync(join(root, "fixture"), `change ${index}`)
+ git("add", "fixture")
+ git("commit", "-m", subject, ...(body ? ["-m", body] : []))
+ }
+ execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
+ cwd: root, stdio: "pipe",
+ env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
+ })
+ const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
+ const output = readFileSync(join(root, "outputs"), "utf8")
+ assert.equal(manifest.version, scenario.version)
+ assert.equal(manifest.tag, `v${scenario.version}`)
+ assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
+ assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
+ assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
+ if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
+ } finally {
+ assert.equal(dirname(resolve(root)), parent)
+ assert.ok(basename(root).startsWith("mm-release-"))
+ rmSync(root, { recursive: true, force: true })
+ }
+})
diff --git a/.ci/scripts/ensure-git-hooks.mjs b/.ci/scripts/ensure-git-hooks.mjs
new file mode 100644
index 00000000..d31575ae
--- /dev/null
+++ b/.ci/scripts/ensure-git-hooks.mjs
@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+import { execFileSync, spawnSync } from "node:child_process"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
+const gitCheck = spawnSync("git", ["rev-parse", "--show-toplevel"], {
+ cwd: repoRoot,
+ encoding: "utf8",
+})
+
+if (gitCheck.status !== 0) {
+ process.exit(0)
+}
+
+const topLevel = gitCheck.stdout.trim()
+
+execFileSync("git", ["config", "core.hooksPath", ".githooks"], {
+ cwd: topLevel,
+ stdio: "ignore",
+})
diff --git a/.ci/scripts/patch-servers-reui.py b/.ci/scripts/patch-servers-reui.py
new file mode 100644
index 00000000..6672da2f
--- /dev/null
+++ b/.ci/scripts/patch-servers-reui.py
@@ -0,0 +1,144 @@
+import re
+from pathlib import Path
+
+path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
+text = path.read_text(encoding="utf-8")
+
+text = text.replace(
+ 'import { Fragment, useEffect, useMemo, useState } from "react"\n'
+ 'import { PageHeader } from "@/components/page-header"\n'
+ 'import { StatusBadge } from "@/components/status-badge"',
+ 'import { useEffect, useMemo, useState } from "react"\n'
+ 'import { PageHeader } from "@/components/page-header"\n'
+ 'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
+ 'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
+ 'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
+ 1,
+)
+
+text = text.replace(
+ """import {
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
+ DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
+} from "@/components/ui/dropdown-menu"
+""",
+ """import {
+ Stepper,
+ StepperContent,
+ StepperIndicator,
+ StepperItem,
+ StepperNav,
+ StepperPanel,
+ StepperSeparator,
+ StepperTitle,
+ StepperTrigger,
+} from "@/components/reui/stepper"
+""",
+ 1,
+)
+
+text = text.replace(
+ """import {
+ SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
+ MoreHorizontalIcon, EyeIcon, EyeOffIcon,
+ ChevronRightIcon, ChevronDownIcon,
+ CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
+ ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
+ HomeIcon, ServerIcon, NetworkIcon,
+} from "lucide-react\"""",
+ """import {
+ RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
+ EyeIcon, EyeOffIcon,
+ ChevronRightIcon, ChevronDownIcon,
+ CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
+ ShieldIcon, WifiIcon,
+ HomeIcon, ServerIcon, NetworkIcon,
+} from "lucide-react\"""",
+ 1,
+)
+
+text = re.sub(
+ r"// ─── RouterOS version utilities.*?// ─── Countries ─",
+ "// ─── Countries ─",
+ text,
+ count=1,
+ flags=re.S,
+)
+
+text = re.sub(
+ r"// ─── Type config ─.*?// ─── Shared small components ─",
+ "// ─── Shared small components ─",
+ text,
+ count=1,
+ flags=re.S,
+)
+
+text = re.sub(
+ r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
+ "// ─── Country field",
+ text,
+ count=1,
+ flags=re.S | re.M,
+)
+
+text = text.replace(
+ "const [expandedId, setExpandedId] = useState(null)",
+ "const [sheetStep, setSheetStep] = useState(1)",
+ 1,
+)
+
+text = text.replace(
+ 'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
+ 'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
+ 1,
+)
+
+text = text.replace(
+ 'setTestState("idle"); setTestMsg(""); setOpen(true)',
+ 'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
+ 1,
+)
+
+text = re.sub(r"", "", text)
+text = re.sub(r"
+ ({
+ value: tab.value,
+ label: tab.label,
+ count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
+ })),
+ }}
+ search={search}
+ onSearchChange={setSearch}
+ searchPlaceholder="Поиск по имени, хосту…"
+ countLabel={`${filtered.length} серверов`}
+ />
+
+
+"""
+
+text = re.sub(
+ r" \{/\* Table \*/\}\n .*? \n",
+ new_table,
+ text,
+ count=1,
+ flags=re.S,
+)
+
+path.write_text(text, encoding="utf-8")
+print("patched", path)
diff --git a/.ci/scripts/patch-servers-stepper.py b/.ci/scripts/patch-servers-stepper.py
new file mode 100644
index 00000000..7d9fc9f3
--- /dev/null
+++ b/.ci/scripts/patch-servers-stepper.py
@@ -0,0 +1,144 @@
+import re
+from pathlib import Path
+
+path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
+text = path.read_text(encoding="utf-8")
+
+# Wrap sheet form in stepper
+text = text.replace(
+ """
+
+ {/* 1. Основные */}
+
+ Основные """,
+ """
+
+
+
+ 1
+ Основные
+
+
+
+
+
+ 2
+ WAN
+
+
+
+
+
+ 3
+ API
+
+
+
+
+
+ 4
+ Дополнительно
+
+
+
+
+ """,
+ 1,
+)
+
+text = text.replace(
+ """
+
+ {/* 2. WAN-аплинки (только для home-router) */}
+ {isHomeRouter && (
+
+ WAN-аплинки """,
+ """
+
+ WAN-аплинки """,
+ 1,
+)
+
+text = text.replace(
+ """
+ )}
+
+ {/* 3. Подключение (API) */}
+
+ Подключение (RouterOS REST API) """,
+ """
+
+ Подключение (RouterOS REST API) """,
+ 1,
+)
+
+text = text.replace(
+ """
+
+ {/* 4. Дополнительно */}
+
+ set("showAdvanced", !form.showAdvanced)}""",
+ """
+
+ set("showAdvanced", !form.showAdvanced)}""",
+ 1,
+)
+
+text = text.replace(
+ """
+
+
+
+
+ }>Отмена
+
+ {sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
+
+ """,
+ """
+
+
+
+
+ }>Отмена
+ {sheetStep > 1 && (
+ setSheetStep((s) => s - 1)}>Назад
+ )}
+ {sheetStep < 4 ? (
+ setSheetStep((s) => s + 1)}>Далее
+ ) : (
+
+ {sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
+
+ )}
+ """,
+ 1,
+)
+
+# WAN step 2: show message when not home router
+text = text.replace(
+ """
+ WAN-аплинки
+
+ WAN-аплинки
+ {!isHomeRouter ? (
+ WAN-аплинки доступны только для типа Home Router.
+ ) : (
+
+
+
+ )}
+
+ line.trim())
+ .find((line) => line.length > 0 && !line.startsWith("#"))
+
+if (!subject) {
+ process.exit(0)
+}
+
+const skipPatterns = [
+ /^Merge\b/i,
+ /^Revert\b/i,
+ /^fixup!/i,
+ /^squash!/i,
+ /^amend!/i,
+]
+
+if (skipPatterns.some((pattern) => pattern.test(subject))) {
+ process.exit(0)
+}
+
+const cyrillic = /[\u0400-\u04FF\u0500-\u052F]/
+
+if (!cyrillic.test(subject)) {
+ console.error("")
+ console.error("Коммит отклонён: subject должен быть на русском.")
+ console.error("Формат: fix(scope): краткое описание на русском")
+ console.error("Правила: .cursor/rules/commit-messages-ru.mdc")
+ console.error("Обход только для аварий: SKIP_COMMIT_MESSAGE_RU=1")
+ console.error("")
+ console.error(`Subject: ${subject}`)
+ process.exit(1)
+}
+
+const conventional =
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:\s*(.+)$/
+
+const match = subject.match(conventional)
+
+if (match && !cyrillic.test(match[3])) {
+ console.error("")
+ console.error("Коммит отклонён: текст после «:» должен быть на русском.")
+ console.error(`Subject: ${subject}`)
+ process.exit(1)
+}
+
+process.exit(0)
diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 00000000..4f75f4a2
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,10 @@
+# Git commit message (Generate Commit Message)
+
+Всегда генерируй subject коммита на русском.
+
+- Формат: `тип(scope): описание на русском` — тип и scope на английском, текст после `:` только на русском.
+- По умолчанию не `feat`: правки UI/багов/CI → `fix`; правила/README/workflow → `chore`/`ci`; `feat` только для новой пользовательской возможности.
+- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`.
+- Запрещено: `feat: update layout`, `fix: add modal`, любой английский subject.
+
+Подробнее: `.cursor/rules/commit-messages-ru.mdc`, semver: `.cursor/rules/release-versioning.mdc`.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..daf13a21
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,23 @@
+.git
+.github
+.gitea
+.ci
+.cursor
+.claude
+node_modules
+.next
+out
+dist
+build
+coverage
+.turbo
+.cache
+*.log
+.env
+.env.*
+tmp
+temp
+.vercel
+backend
+packages/contracts/dist
+backend/dist
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..a8196ef7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+deploy/netflow-agent/*.sh text eol=lf
+deploy/netflow-agent/*.py text eol=lf
+# Migration hashes authenticate exact reviewed bytes, including existing CRLF files.
+# Preserve those bytes on every checkout instead of applying core.autocrlf.
+backend/drizzle/*.sql -text
diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml
new file mode 100644
index 00000000..ae32687f
--- /dev/null
+++ b/.gitea/workflows/docker.yml
@@ -0,0 +1,498 @@
+name: Docker images
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+env:
+ REGISTRY: git.shx.one
+ GITEA_SERVER_URL: https://git.shx.one
+
+jobs:
+ prepare-release:
+ runs-on: ubuntu-latest
+ outputs:
+ should_release: ${{ steps.release.outputs.should_release }}
+ version: ${{ steps.release.outputs.version }}
+ tag: ${{ steps.release.outputs.tag }}
+ bump: ${{ steps.release.outputs.bump }}
+ release_url: ${{ steps.release.outputs.release_url }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Test release version calculation
+ run: node --test .ci/scripts/compute-release.test.mjs
+
+ - name: Compute release metadata
+ id: release
+ shell: bash
+ env:
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ GITEA_SERVER_URL: ${{ env.GITEA_SERVER_URL }}
+ BASE_VERSION: "1.0.0"
+ RELEASE_OUTPUT_DIR: .ci/release
+ run: |
+ set -euo pipefail
+ node .ci/scripts/compute-release.mjs
+ test -f .ci/release/release-manifest.json
+
+ backend-test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node 22
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Start PostgreSQL 18
+ shell: bash
+ run: |
+ set -euo pipefail
+ NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
+ docker rm -f "$NAME" 2>/dev/null || true
+ # Docker-backed Gitea runners execute this step in a separate container.
+ # Join its network; a published host loopback port is not reachable here.
+ NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
+ if [ -n "$NETWORKS" ]; then
+ test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
+ NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
+ PG_HOST="$NAME"
+ PG_PORT=5432
+ else
+ # Host-based runners can use a Docker-assigned ephemeral host port.
+ NETWORK_ARGS=(-p 127.0.0.1::5432)
+ PG_HOST=127.0.0.1
+ fi
+ docker run -d --name "$NAME" --rm \
+ -e POSTGRES_USER=mmapp \
+ -e POSTGRES_PASSWORD=mmapp \
+ -e POSTGRES_DB=mmapp \
+ "${NETWORK_ARGS[@]}" \
+ postgres:18-alpine
+ echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
+ if [ "$PG_HOST" = 127.0.0.1 ]; then
+ PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
+ fi
+ echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
+ for i in $(seq 1 40); do
+ if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "PostgreSQL не поднялся"
+ docker logs "$NAME" || true
+ exit 1
+
+ - name: Install and test backend
+ shell: bash
+ env:
+ REQUIRE_TEST_POSTGRES: "1"
+ run: |
+ set -euo pipefail
+ npm ci
+ node --input-type=module <<'NODE'
+ import pg from 'pg';
+ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
+ try {
+ await pool.query('SELECT 1');
+ console.log('PostgreSQL reachable from the test process');
+ } finally {
+ await pool.end();
+ }
+ NODE
+ npm run test --prefix backend
+
+ - name: Stop PostgreSQL
+ if: always()
+ shell: bash
+ run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
+
+ backend-image:
+ needs:
+ - prepare-release
+ - backend-test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure backend image name
+ shell: bash
+ run: |
+ set -euo pipefail
+ owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-backend" >> "${GITHUB_ENV}"
+
+ - name: Prepare backend build context
+ shell: bash
+ run: |
+ set -euo pipefail
+ STAGING=".ci/docker/backend"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
+ 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 -R packages/contracts/src "$STAGING/packages/contracts/"
+ cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
+ cp -R backend/src "$STAGING/backend/"
+ mkdir -p "$STAGING/backend/scripts/writer-profile"
+ cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
+ cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
+ mkdir -p "$STAGING/deploy/netflow-agent"
+ cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
+ if [ -d backend/drizzle ]; then
+ cp -R backend/drizzle "$STAGING/backend/"
+ fi
+
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+
+ - name: Build and push backend image
+ uses: docker/build-push-action@v5
+ with:
+ context: .ci/docker/backend
+ file: backend/Dockerfile
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ APP_VERSION=${{ needs.prepare-release.outputs.version }}
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+ ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
+ cache-from: type=gha,scope=backend
+ cache-to: type=gha,mode=max,scope=backend
+ labels: |
+ org.opencontainers.image.title=mmapp-backend
+ org.opencontainers.image.description=MikroTik Manager backend (Fastify)
+ org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
+
+ frontend-image:
+ needs: prepare-release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Compute release metadata
+ shell: bash
+ env:
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ GITEA_SERVER_URL: ${{ env.GITEA_SERVER_URL }}
+ BASE_VERSION: "1.0.0"
+ RELEASE_OUTPUT_DIR: .ci/release
+ run: |
+ set -euo pipefail
+ node .ci/scripts/compute-release.mjs
+ test -f .ci/release/release-manifest.json
+
+ - name: Configure frontend image name
+ shell: bash
+ run: |
+ set -euo pipefail
+ owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-frontend" >> "${GITHUB_ENV}"
+
+ - name: Prepare frontend build context
+ shell: bash
+ run: |
+ set -euo pipefail
+ STAGING=".ci/docker/frontend"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
+ cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
+ node <<'NODE'
+ const fs = require("node:fs")
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
+ pkg.workspaces = ["packages/*"]
+ fs.writeFileSync(".ci/docker/frontend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
+ NODE
+ cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
+ cp -R packages/contracts/src "$STAGING/packages/contracts/"
+ for dir in app components lib shared entities public hooks; do
+ if [ -d "$dir" ]; then
+ cp -R "$dir" "$STAGING/"
+ fi
+ done
+ cp .ci/release/release-manifest.json "$STAGING/public/release-manifest.json"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+
+ - name: Build and push frontend image
+ uses: docker/build-push-action@v5
+ with:
+ context: .ci/docker/frontend
+ file: Dockerfile.frontend
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ BACKEND_INTERNAL_URL=http://backend:8000
+ NEXT_PUBLIC_BACKEND_URL=same-origin
+ NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
+ 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_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+ ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
+ cache-from: type=gha,scope=frontend
+ cache-to: type=gha,mode=max,scope=frontend
+ labels: |
+ org.opencontainers.image.title=mmapp-frontend
+ org.opencontainers.image.description=MikroTik Manager frontend (Next.js)
+ org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
+
+ updater-image:
+ needs: prepare-release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure updater image name
+ shell: bash
+ run: |
+ set -euo pipefail
+ owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-updater" >> "${GITHUB_ENV}"
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+
+ - name: Build and push updater image
+ uses: docker/build-push-action@v5
+ with:
+ context: deploy/updater
+ file: deploy/updater/Dockerfile
+ platforms: linux/amd64
+ push: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+ ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
+ cache-from: type=gha,scope=updater
+ cache-to: type=gha,mode=max,scope=updater
+ labels: |
+ org.opencontainers.image.title=mmapp-updater
+ org.opencontainers.image.description=MikroTik Manager autonomous Docker updater
+ org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
+
+ netflow-agent-image:
+ needs: prepare-release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure netflow-agent image name
+ shell: bash
+ run: |
+ set -euo pipefail
+ owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
+
+ - name: Test controller and installer
+ run: |
+ python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
+ bash -n deploy/netflow-agent/install.sh
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+
+ - name: Verify image with real WireGuard
+ run: |
+ docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
+ docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
+
+ - name: Build and push netflow-agent image
+ uses: docker/build-push-action@v5
+ with:
+ context: deploy/netflow-agent
+ file: deploy/netflow-agent/Dockerfile
+ platforms: linux/amd64
+ push: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:latest
+ ${{ env.IMAGE_NAME }}:${{ gitea.sha }}
+ ${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
+ cache-from: type=gha,scope=netflow-agent
+ cache-to: type=gha,mode=max,scope=netflow-agent
+ labels: |
+ org.opencontainers.image.title=mmapp-netflow-agent
+ org.opencontainers.image.description=MikroTik Manager local WireGuard controller
+ org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
+
+ publish-release:
+ needs:
+ - prepare-release
+ - backend-image
+ - frontend-image
+ - updater-image
+ - netflow-agent-image
+ if: needs.prepare-release.outputs.should_release == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Compute release metadata
+ shell: bash
+ env:
+ GITEA_REPOSITORY: ${{ gitea.repository }}
+ GITEA_SERVER_URL: ${{ env.GITEA_SERVER_URL }}
+ BASE_VERSION: "1.0.0"
+ RELEASE_OUTPUT_DIR: .ci/release
+ run: |
+ set -euo pipefail
+ node .ci/scripts/compute-release.mjs
+ test -f .ci/release/release_notes.md
+
+ - name: Configure git author
+ shell: bash
+ run: |
+ set -euo pipefail
+ git config user.name "gitea-actions[bot]"
+ git config user.email "gitea-actions[bot]@users.noreply.gitea"
+
+ - name: Create and push release tag
+ shell: bash
+ env:
+ TAG: ${{ needs.prepare-release.outputs.tag }}
+ run: |
+ set -euo pipefail
+ if git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "Tag $TAG already exists, skipping tag push"
+ exit 0
+ fi
+ git tag -a "$TAG" -m "Release $TAG"
+ git push origin "$TAG"
+
+ - name: Publish Gitea release
+ shell: bash
+ env:
+ TAG: ${{ needs.prepare-release.outputs.tag }}
+ VERSION: ${{ needs.prepare-release.outputs.version }}
+ REPOSITORY: ${{ gitea.repository }}
+ TOKEN: ${{ secrets.ACTIONS_PAT }}
+ run: |
+ set -euo pipefail
+ owner="${REPOSITORY%%/*}"
+ repo="${REPOSITORY#*/}"
+ payload=$(node <<'NODE'
+ const fs = require("node:fs")
+ const body = fs.readFileSync(".ci/release/release_notes.md", "utf8")
+ const payload = {
+ tag_name: process.env.TAG,
+ target_commitish: "main",
+ name: process.env.VERSION,
+ body,
+ draft: false,
+ prerelease: false,
+ }
+ process.stdout.write(JSON.stringify(payload))
+ NODE
+ )
+ status=$(curl --silent --show-error --output /tmp/gitea-release.json --write-out "%{http_code}" \
+ --request POST \
+ --header "Authorization: token ${TOKEN}" \
+ --header "Content-Type: application/json" \
+ --data "$payload" \
+ "${{ env.GITEA_SERVER_URL }}/api/v1/repos/${owner}/${repo}/releases")
+ if [ "$status" = "409" ] || [ "$status" = "422" ]; then
+ echo "Release for ${TAG} already exists (${status}), skipping"
+ exit 0
+ fi
+ if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
+ cat /tmp/gitea-release.json
+ exit 1
+ fi
+
+ notify-webhook:
+ needs:
+ - prepare-release
+ - backend-image
+ - frontend-image
+ - netflow-agent-image
+ runs-on: ubuntu-latest
+ env:
+ WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
+ steps:
+ - name: Notify deploy webhook
+ if: env.WEBHOOK_URL != ''
+ shell: bash
+ run: |
+ set -euo pipefail
+ curl --fail --silent --show-error --request POST \
+ --header "Content-Type: application/json" \
+ --data "{\"repository\":\"${{ gitea.repository }}\",\"sha\":\"${{ gitea.sha }}\",\"ref\":\"${{ gitea.ref }}\",\"version\":\"${{ needs.prepare-release.outputs.version }}\",\"tag\":\"${{ needs.prepare-release.outputs.tag }}\",\"releaseUrl\":\"${{ needs.prepare-release.outputs.release_url }}\"}" \
+ "$WEBHOOK_URL"
diff --git a/.gitea/workflows/mcp-acceptance-preview.yml b/.gitea/workflows/mcp-acceptance-preview.yml
new file mode 100644
index 00000000..36bdf2d8
--- /dev/null
+++ b/.gitea/workflows/mcp-acceptance-preview.yml
@@ -0,0 +1,276 @@
+name: MCP isolated acceptance preview images
+'on':
+ workflow_dispatch:
+ inputs:
+ expected_sha:
+ description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
+ required: true
+ type: string
+ auth_portal_origin:
+ description: Canonical isolated browser-facing Auth Portal HTTP(S) origin; no path, credentials or query
+ required: true
+ type: string
+ default: http://127.0.0.1:55175
+permissions:
+ contents: read
+ packages: write
+jobs:
+ backend-preview:
+ if: ${{ gitea.event_name == 'workflow_dispatch' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ env:
+ COMPONENT: backend
+ EXPECTED_SHA: ${{ inputs.expected_sha }}
+ DISPATCH_SHA: ${{ gitea.sha }}
+ DISPATCH_EVENT: ${{ gitea.event_name }}
+ REPOSITORY: ${{ gitea.repository }}
+ RUN_ID: ${{ gitea.run_id }}
+ RUN_ATTEMPT: ${{ gitea.run_attempt }}
+ steps:
+ - name: Checkout exact dispatch commit
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ gitea.sha }}
+ persist-credentials: false
+ - name: Use existing Linux Node22 build toolchain
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Verify source and isolated preview destination
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$DISPATCH_EVENT" = workflow_dispatch
+ [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
+ test "$EXPECTED_SHA" = "$DISPATCH_SHA"
+ test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
+ git diff --exit-code
+ git diff --cached --exit-code
+ [[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
+ owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ [[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
+ echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
+ echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
+ mkdir -p .ci/acceptance-evidence
+ node <<'NODE'
+ const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
+ const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
+ const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
+ fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
+ NODE
+ - name: Prepare existing scoped Docker context
+ shell: bash
+ run: |
+ set -euo pipefail
+ STAGING=".ci/docker/backend"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
+ 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 -R packages/contracts/src "$STAGING/packages/contracts/"
+ cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
+ cp -R backend/src "$STAGING/backend/"
+ mkdir -p "$STAGING/deploy/netflow-agent"
+ cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
+ if [ -d backend/drizzle ]; then
+ cp -R backend/drizzle "$STAGING/backend/"
+ fi
+ mkdir -p "$STAGING/backend/scripts/writer-profile"
+ cp backend/scripts/build-database-operator.mjs backend/scripts/database-writer-bootstrap-operator.mjs backend/scripts/database-writer-profile-operator.mts "$STAGING/backend/scripts/"
+ cp backend/scripts/writer-profile/operator-files.mjs backend/scripts/writer-profile/operator-contract.mjs backend/scripts/writer-profile/publisher.mjs backend/scripts/writer-profile/context.mjs backend/scripts/writer-profile/catalogue.mts backend/scripts/writer-profile/catalogue-value-bridge.ts backend/scripts/writer-profile/catalogue-projection.ts backend/scripts/writer-profile/catalogue-leaves.ts backend/scripts/writer-profile/application-writer-profile-catalogue.ts backend/scripts/writer-profile/application-writer-profile-catalogue-values.ts backend/scripts/writer-profile/application-writer-profile-catalogue-parent-policy.ts backend/scripts/writer-profile/application-writer-profile-canonical.ts backend/scripts/writer-profile/fixed44-build-sql.ts backend/scripts/writer-profile/fixed44-build-definition.ts backend/scripts/writer-profile/expanded44-data-policy.ts "${STAGING}/backend/scripts/writer-profile/"
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Log in to existing Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+ - name: Build and push isolated acceptance image
+ id: build
+ uses: docker/build-push-action@v5
+ with:
+ context: .ci/docker/backend
+ file: backend/Dockerfile
+ platforms: linux/amd64
+ push: true
+ tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
+ build-args: APP_VERSION=${{ env.PREVIEW_TAG }}
+ cache-from: type=gha,scope=mcp-acceptance-backend
+ cache-to: type=gha,scope=mcp-acceptance-backend,mode=max
+ provenance: mode=min
+ sbom: true
+ labels: |-
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
+ io.mmapp.acceptance-only=true
+ - name: Record exact digest and build metadata
+ shell: bash
+ env:
+ IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
+ BUILD_METADATA: ${{ steps.build.outputs.metadata }}
+ run: |
+ set -euo pipefail
+ node <<'NODE'
+ const fs=require('node:fs'),crypto=require('node:crypto');
+ const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
+ const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
+ fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
+ console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
+ NODE
+ - name: Retain source and image binding artifacts
+ uses: actions/upload-artifact@v3
+ with:
+ name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
+ path: .ci/acceptance-evidence/*.json
+ if-no-files-found: error
+ retention-days: 14
+ frontend-preview:
+ if: ${{ gitea.event_name == 'workflow_dispatch' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ env:
+ COMPONENT: frontend
+ EXPECTED_SHA: ${{ inputs.expected_sha }}
+ DISPATCH_SHA: ${{ gitea.sha }}
+ DISPATCH_EVENT: ${{ gitea.event_name }}
+ REPOSITORY: ${{ gitea.repository }}
+ RUN_ID: ${{ gitea.run_id }}
+ RUN_ATTEMPT: ${{ gitea.run_attempt }}
+ AUTH_PORTAL_ORIGIN_REQUEST: ${{ inputs.auth_portal_origin }}
+ steps:
+ - name: Checkout exact dispatch commit
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ gitea.sha }}
+ persist-credentials: false
+ - name: Use existing Linux Node22 build toolchain
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Validate explicit isolated portal origin
+ shell: bash
+ run: |
+ set -euo pipefail
+ node <<'NODE'
+ const fs=require('node:fs'),assert=require('node:assert/strict');
+ const raw=process.env.AUTH_PORTAL_ORIGIN_REQUEST;
+ assert(typeof raw==='string'&&raw.length>0&&raw.length<=2048&&!/[\x00-\x20\x7f]/.test(raw),'Bounded canonical HTTP(S) origin required');
+ const origin=new URL(raw);
+ assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.pathname==='/'&&!origin.search&&!origin.hash&&origin.origin!=='null'&&origin.origin===raw,'Canonical origin only; credentials/path/query/hash refused');
+ fs.appendFileSync(process.env.GITHUB_ENV,'AUTH_PORTAL_ORIGIN='+origin.origin+'\n');
+ NODE
+ - name: Verify source and isolated preview destination
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$DISPATCH_EVENT" = workflow_dispatch
+ [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
+ test "$EXPECTED_SHA" = "$DISPATCH_SHA"
+ test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
+ git diff --exit-code
+ git diff --cached --exit-code
+ [[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
+ owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
+ name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
+ [[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
+ stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
+ echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
+ echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
+ echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
+ mkdir -p .ci/acceptance-evidence
+ node <<'NODE'
+ const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
+ const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
+ const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
+ fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
+ NODE
+ - name: Prepare existing scoped Docker context
+ shell: bash
+ run: |
+ set -euo pipefail
+ STAGING=".ci/docker/frontend"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
+ cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
+ node <<'NODE'
+ const fs = require("node:fs")
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
+ pkg.workspaces = ["packages/*"]
+ fs.writeFileSync(".ci/docker/frontend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
+ NODE
+ cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
+ cp -R packages/contracts/src "$STAGING/packages/contracts/"
+ for dir in app components lib shared entities public hooks; do
+ if [ -d "$dir" ]; then
+ cp -R "$dir" "$STAGING/"
+ fi
+ done
+ test -f "$STAGING/public/release-manifest.json"
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ - name: Log in to existing Gitea registry
+ uses: docker/login-action@v3
+ with:
+ registry: git.shx.one
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.ACTIONS_PAT }}
+ - name: Build and push isolated acceptance image
+ id: build
+ uses: docker/build-push-action@v5
+ with:
+ context: .ci/docker/frontend
+ file: Dockerfile.frontend
+ platforms: linux/amd64
+ push: true
+ tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
+ build-args: |-
+ BACKEND_INTERNAL_URL=http://backend:8000
+ NEXT_PUBLIC_BACKEND_URL=same-origin
+ NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
+ NEXT_PUBLIC_ALLOW_MOCK_DATA=false
+ NEXT_PUBLIC_AUTH_PORTAL_URL=${{ env.AUTH_PORTAL_ORIGIN }}
+ NEXT_PUBLIC_APP_VERSION=${{ env.PREVIEW_TAG }}
+ NEXT_PUBLIC_RELEASE_URL=
+ cache-from: type=gha,scope=mcp-acceptance-frontend
+ cache-to: type=gha,scope=mcp-acceptance-frontend,mode=max
+ provenance: mode=min
+ sbom: true
+ labels: |-
+ org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
+ io.mmapp.acceptance-only=true
+ - name: Record exact digest and build metadata
+ shell: bash
+ env:
+ IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
+ BUILD_METADATA: ${{ steps.build.outputs.metadata }}
+ run: |
+ set -euo pipefail
+ node <<'NODE'
+ const fs=require('node:fs'),crypto=require('node:crypto');
+ const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
+ const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
+ fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
+ console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
+ NODE
+ - name: Retain source and image binding artifacts
+ uses: actions/upload-artifact@v3
+ with:
+ name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
+ path: .ci/acceptance-evidence/*.json
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100644
index 00000000..4be0979b
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+node "$(dirname "$0")/../.ci/scripts/validate-commit-message.mjs" "$1"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..d937d262
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,80 @@
+# dependencies
+node_modules/
+
+# next.js
+.next/
+out/
+
+# production
+build/
+dist/
+
+# turbo
+.turbo/
+
+# logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+pnpm-debug.log*
+
+# env files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# cache
+.cache/
+.parcel-cache/
+
+# coverage
+coverage/
+test-results/
+playwright-report/
+test-results/
+playwright-report/
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# IDE
+.idea/
+.vscode/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# misc
+.vercel/
+tmp/
+temp/
+.mcp-state/
+backend/.mcp-state/
+.ci/docker/
+.claude/settings.local.json
+
+# Local MCP configs (may contain REUI license Bearer)
+.cursor/mcp.json
+.mcp.json
+opencode.json
+
+# Local codegraph process
+.codegraph/daemon.pid
+
+# Python controller tests
+__pycache__/
+*.pyc
+
+# NetFlow agent host state (when Compose is installed in a checkout)
+/data/netflow-wireguard/
+/data/netflow-agent-socket/
+# Compiled disposable memory-acceptance entry; not a production image artifact.
+backend/dist-mcp-memory/
+backend/dist-operator/
+
+# Agent-local permissions can contain commands with deployment credentials.
+/.claude/settings.local.json
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..7ebd1707
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,23 @@
+# MikrotikManager — инструкции для агента
+
+## Сообщения коммитов
+
+При **Generate Commit Message** и любой автогенерации commit message:
+
+1. Subject после `:` — **только на русском**.
+2. **`feat` не по умолчанию.** Правка UI/бага/CI → `fix`; правила/README/workflow → `chore`/`ci`; новая пользовательская фича → `feat`.
+3. Формат: `fix(ui): выровнять модалку релиза`.
+4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
+
+Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
+
+## Локальный запуск
+
+Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
+
+## ReUI
+
+Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
+Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
+Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
+Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
new file mode 100644
index 00000000..9869b161
--- /dev/null
+++ b/Dockerfile.frontend
@@ -0,0 +1,50 @@
+# syntax=docker/dockerfile:1
+
+FROM node:22-bookworm-slim AS deps
+WORKDIR /app
+ENV npm_config_ignore_scripts=true
+COPY package.json package-lock.json ./
+COPY packages/contracts/package.json packages/contracts/
+RUN npm ci --workspace=@mmapp/contracts --include-workspace-root --ignore-scripts
+
+FROM deps AS build
+ARG BACKEND_INTERNAL_URL=http://backend:8000
+ARG NEXT_PUBLIC_BACKEND_URL=same-origin
+ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
+ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
+ARG NEXT_PUBLIC_APP_VERSION=dev
+ARG NEXT_PUBLIC_RELEASE_URL=
+ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
+ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
+ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
+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_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
+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 next.config.ts tsconfig.json postcss.config.mjs components.json ./
+COPY app app
+COPY components components
+COPY lib lib
+COPY shared shared
+COPY entities entities
+COPY public public
+COPY hooks hooks
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build -w @mmapp/contracts \
+ && npm run build \
+ && npm prune --omit=dev
+
+FROM node:22-bookworm-slim AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV HOSTNAME=0.0.0.0
+ENV PORT=3000
+ENV BACKEND_INTERNAL_URL=http://backend:8000
+COPY --from=build /app/public ./public
+COPY --from=build /app/.next/standalone ./
+COPY --from=build /app/.next/static ./.next/static
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..61f365ba
--- /dev/null
+++ b/README.md
@@ -0,0 +1,574 @@
+# MikrotikManager-3
+
+Монорепозиторий веб-приложения для управления MikroTik: UI на Next.js, API на Fastify, общие Zod-контракты, сборка Docker-образов через Gitea Actions и автоматическое обновление контейнеров на сервере через sidecar updater.
+
+## Содержание
+
+1. [Состав монорепозитория](#состав-монорепозитория)
+2. [Архитектура](#архитектура)
+3. [Локальная разработка](#локальная-разработка)
+4. [CI/CD (Gitea Actions)](#cicd-gitea-actions)
+5. [Прод-развёртывание Docker](#прод-развёртывание-docker)
+6. [Сервис updater](#сервис-updater)
+7. [Первичная настройка сервера](#первичная-настройка-сервера)
+8. [Эксплуатация и сопровождение](#эксплуатация-и-сопровождение)
+9. [Чеклист деплоя](#чеклист-деплоя)
+
+## Состав монорепозитория
+
+| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
+|-----------|------|------|----------------|--------------|
+| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
+| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
+| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
+| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
+| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
+| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
+
+Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
+
+- `git.shx.one/denozord/mikrotikmanager-backend`
+- `git.shx.one/denozord/mikrotikmanager-frontend`
+- `git.shx.one/denozord/mikrotikmanager-updater`
+
+Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
+
+## Архитектура
+
+### Общая схема
+
+```mermaid
+flowchart TB
+ subgraph dev [Локальная разработка]
+ Browser["Браузер"]
+ NextDev["Next.js :3000"]
+ FastifyDev["Fastify :8000"]
+ PostgresDev["PostgreSQL 18"]
+ Contracts["@mmapp/contracts"]
+ Browser --> NextDev
+ Browser -->|"fetch /api, CORS"| FastifyDev
+ FastifyDev --> PostgresDev
+ Contracts --> NextDev
+ Contracts --> FastifyDev
+ end
+
+ subgraph ci [Gitea Actions]
+ WF[".gitea/workflows/docker.yml"]
+ WF --> Reg["git.shx.one registry"]
+ end
+
+ subgraph prod [Прод-сервер]
+ FE["mmapp-frontend :3000"]
+ BE["mmapp-backend :8000"]
+ PG["mmapp-postgres"]
+ DBVol["volume sqlite ETL /app/data"]
+ UPD["mmapp-updater"]
+ Sock["/var/run/docker.sock"]
+ Reg --> FE
+ Reg --> BE
+ Reg --> UPD
+ UPD --> Sock
+ UPD --> FE
+ UPD --> BE
+ BE --> PG
+ BE --> DBVol
+ BrowserProd["Клиент"] --> FE
+ BrowserProd -->|"live + backendUrl"| BE
+ end
+```
+
+### Frontend и backend
+
+- Браузер загружает UI с порта **3000**.
+- Запросы к API выполняются **напрямую с клиента** (`fetch`) на URL бэкенда. Next.js rewrites/proxy **не используются** (`next.config.ts` — только `output: "standalone"`).
+- URL бэкенда и режим данных (`mock` / `live`) хранятся в `localStorage` (`routerlists:data-source`, `routerlists:backend-url`); по умолчанию `http://localhost:8000`. Настройка — страница **Настройки** в UI (`app/(main)/settings/page.tsx`, провайдер `lib/data-source.tsx`).
+- Backend слушает **8000**, отдаёт `GET /health` и маршруты под `/api/…`. CORS разрешён для **одного** origin — переменная `CORS_ORIGIN` (`backend/src/config.ts`, `backend/src/index.ts`).
+- Отдельно от UI-режима: `NEXT_PUBLIC_API_MODE` (`mock` по умолчанию, `live` для RouterOS REST) — `lib/api-mock.ts`; для работы с API приложения через бэкенд в проде достаточно режима **live** в UI и корректного `backendUrl`.
+
+### Общие контракты (`packages/contracts`)
+
+- npm workspaces: корень + `packages/*` + `backend` (`package.json`).
+- Пакет `@mmapp/contracts`: сборка `tsc` → `dist/`, exports `./servers`, `./events`, `./alerts`.
+- При `npm install` выполняется `postinstall` → `npm run build -w @mmapp/contracts`.
+- Backend: валидация и типы в маршрутах; frontend: типы и разбор ответов в `shared/api/`.
+
+### Поток образов: CI → registry → сервер
+
+```mermaid
+sequenceDiagram
+ participant Gitea
+ participant Actions
+ participant Registry
+ participant Updater
+ participant Backend
+ participant Frontend
+
+ Gitea->>Actions: push main / workflow_dispatch
+ Actions->>Registry: push backend/frontend/updater :latest и :sha
+ Actions-->>Gitea: optional DEPLOY_WEBHOOK_URL POST
+ loop каждые POLL_INTERVAL_SECONDS
+ Updater->>Registry: imagetools inspect digest
+ alt digest изменился
+ Updater->>Registry: docker pull
+ Updater->>Backend: stop/rm/run из snapshot
+ Updater->>Frontend: stop/rm/run из snapshot
+ Updater->>Backend: HTTP health
+ Updater->>Frontend: HTTP health
+ end
+ end
+```
+
+- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
+- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
+- Теги на каждый успешный push: **`:latest`** и **`:`**; платформа **linux/amd64**.
+- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
+
+### Зависимости в проде
+
+| Зависимость | Реализация |
+|-------------|------------|
+| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
+| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
+| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
+
+## Локальная разработка
+
+### Требования
+
+- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
+- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
+- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
+- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
+- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
+
+### Запуск
+
+Два процесса (frontend и backend):
+
+```powershell
+npm install
+npm run dev
+```
+
+```powershell
+npm --prefix backend run dev
+```
+
+| Сервис | URL | Проверка |
+|--------|-----|----------|
+| Frontend | http://localhost:3000 | открыть в браузере |
+| Backend | http://localhost:8000 | `curl -fsS http://localhost:8000/health` |
+
+### Переменные окружения (разработка)
+
+Backend — скопировать `backend/.env.example` в `backend/.env`:
+
+| Переменная | По умолчанию | Назначение |
+|------------|--------------|------------|
+| `DATABASE_URL` | `postgres://mmapp:mmapp@127.0.0.1:5432/mmapp` | подключение к PostgreSQL |
+| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
+| `PORT` | `8000` | порт Fastify |
+| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
+
+Frontend — в репозитории нет корневого `.env.example`. Опционально `.env.local`:
+
+| Переменная | По умолчанию | Назначение |
+|------------|--------------|------------|
+| `NEXT_PUBLIC_API_MODE` | `mock` | `live` — вызовы RouterOS REST через `lib/api-mock.ts` |
+
+Режим **live** для API приложения и URL бэкенда задаются в UI (не через build-time env).
+
+### Контракты и БД в dev
+
+```bash
+npm run build -w @mmapp/contracts
+```
+
+Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
+
+```bash
+npm --prefix backend run db:migrate-from-sqlite
+```
+
+### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
+
+Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
+
+Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
+
+Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
+
+## CI/CD (Gitea Actions)
+
+Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
+
+| Job | Build context | Dockerfile | Имя образа |
+|-----|---------------|------------|------------|
+| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one//-backend` |
+| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one//-frontend` |
+| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one//-updater` |
+
+- `` — первая часть `gitea.repository`, lower case.
+- `` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`).
+
+Логин в registry в CI: `gitea.actor` + секрет **`ACTIONS_PAT`**.
+
+Кэш Buildx: `type=gha`, отдельные scope `backend`, `frontend`, `updater`.
+
+Опциональный job **`notify-webhook`**: POST JSON `{"repository","sha","ref","version","tag","releaseUrl"}` на URL из секрета **`DEPLOY_WEBHOOK_URL`** после успешной сборки backend и frontend и публикации релиза (updater в `needs` не входит).
+
+### Версионирование и релизы
+
+- Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
+- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
+- **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
+- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:`.
+- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
+- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:`, `:`.
+- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
+- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
+- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
+
+## Прод-развёртывание Docker
+
+Эталон без 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` из корня).
+
+### Прод-контейнеры
+
+| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
+|--------|------------------|----------------|----------------------|------|-----------|
+| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
+| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
+| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
+| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
+
+Метки для updater на backend и frontend:
+
+| Метка | Пример значения |
+|-------|-----------------|
+| `mmapp.updater.managed` | `true` |
+| `mmapp.updater.target` | `backend` / `frontend` |
+| `mmapp.updater.image` | полное имя образа с тегом |
+
+Явной пользовательской сети в compose нет — используется сеть проекта Compose по умолчанию.
+
+### Переменные окружения (прод)
+
+**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
+
+| Переменная | Источник в compose | Назначение |
+|------------|-------------------|------------|
+| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
+| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
+| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
+| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
+
+**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
+
+**Updater**:
+
+| Переменная | По умолчанию | Назначение |
+|------------|--------------|------------|
+| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
+| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
+| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
+| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
+| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
+| `REGISTRY` | `git.shx.one` | registry для `docker login` |
+| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
+| `REGISTRY_PASSWORD` | пусто | пароль registry |
+
+### Docker Compose
+
+```bash
+docker login git.shx.one
+export CORS_ORIGIN=http://<хост>:3000
+export REGISTRY_USERNAME=
+export REGISTRY_PASSWORD=
+```
+
+Скопировать и отредактировать конфиг updater (health URL должны быть достижимы **из контейнера updater**):
+
+```bash
+cp deploy/updater/targets.json.example deploy/updater/targets.json
+```
+
+В `deploy/docker-compose.yml` для updater заменить bind-mount на рабочий файл:
+
+```yaml
+- ./updater/targets.json:/etc/updater/targets.json:ro
+```
+
+Запуск:
+
+```bash
+cd deploy
+docker compose pull
+docker compose up -d
+```
+
+### Эквивалентные `docker run`
+
+Имена томов можно согласовать с compose (`backend-data`, `updater-state`) или задать явно.
+
+**Backend:**
+
+```bash
+docker volume create backend-data
+docker run -d \
+ --name mmapp-backend \
+ --restart unless-stopped \
+ -p 8000:8000 \
+ -e CORS_ORIGIN=http://localhost:3000 \
+ -v backend-data:/app/data \
+ --label mmapp.updater.managed=true \
+ --label mmapp.updater.target=backend \
+ --label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
+ git.shx.one/denozord/mikrotikmanager-backend:latest
+```
+
+**Frontend:**
+
+```bash
+docker run -d \
+ --name mmapp-frontend \
+ --restart unless-stopped \
+ -p 3000:3000 \
+ --label mmapp.updater.managed=true \
+ --label mmapp.updater.target=frontend \
+ --label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
+ git.shx.one/denozord/mikrotikmanager-frontend:latest
+```
+
+**Updater:**
+
+```bash
+docker volume create updater-state
+docker run -d \
+ --name mmapp-updater \
+ --restart unless-stopped \
+ -e REGISTRY=git.shx.one \
+ -e REGISTRY_USERNAME= \
+ -e REGISTRY_PASSWORD= \
+ -e POLL_INTERVAL_SECONDS=300 \
+ -e HEALTH_TIMEOUT_SECONDS=120 \
+ -e STOP_TIMEOUT_SECONDS=30 \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v updater-state:/state \
+ -v /path/to/targets.json:/etc/updater/targets.json:ro \
+ git.shx.one/denozord/mikrotikmanager-updater:latest
+```
+
+### Проверка после деплоя
+
+| Проверка | Команда / ожидание |
+|----------|-------------------|
+| Backend health | `curl -fsS http://127.0.0.1:8000/health` → JSON со `status` |
+| Frontend | `curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/` → `200` |
+| Контейнеры | `docker ps --filter name=mmapp-` |
+| UI | режим **live**, URL бэкенда совпадает с доступом клиента и с `CORS_ORIGIN` |
+
+## Сервис updater
+
+Исходники: `deploy/updater/entrypoint.sh`, образ `deploy/updater/Dockerfile`.
+
+### Назначение
+
+Sidecar на хосте с Docker: периодически сравнивает **digest манифеста** образа в registry с последним применённым digest, при изменении выполняет `docker pull`, пересоздаёт целевой контейнер и проверяет HTTP health. Состояние — файл `STATE_FILE` (том `updater-state`).
+
+### Обнаружение новых образов
+
+- `docker buildx imagetools inspect ` → digest манифеста (тег в `targets.json`, обычно `:latest`).
+- Сравнение с `last_applied_digest` в state; при совпадении — noop.
+- При первом запуске — baseline из digest работающего контейнера или remote, чтобы не пересоздавать без смены digest.
+- Если remote digest недоступен — running контейнер **не** останавливается, в state пишется ошибка.
+
+### Перезапуск backend/frontend
+
+Только контейнеры с метками `mmapp.updater.managed=true`, совпадающими `mmapp.updater.target` и `mmapp.updater.image` с записью в `targets.json`.
+
+Последовательность: `docker inspect` (snapshot) → `docker pull` → `docker stop` → `docker rm` → `docker run -d` с восстановлением env, mounts, `-p`, `--network`, labels, restart policy из snapshot.
+
+При неуспешном health после обновления — откат на `previous_digest` (`image@digest`). Если предыдущего digest нет — откат пропускается (лог `rollback_skipped`).
+
+### Конфигурация целей
+
+Пример: `deploy/updater/targets.json.example`. Поля цели: `id`, `container_name`, `image`, `health.type`, `health.url`, `health.expect_status`.
+
+**Важно:** в примере health URL — `http://127.0.0.1:8000/health` и `http://127.0.0.1:3000/`. В default bridge-сети контейнера updater `127.0.0.1` указывает на **сам updater**, а не на backend/frontend. Перед продом задайте URL, **достижимые из контейнера updater** (например опубликованные порты хоста при `network_mode: host` у updater, `host.docker.internal` с `extra_hosts`, или иной согласованный с вашей сетью вариант). Скопируйте example в `deploy/updater/targets.json` и отредактируйте.
+
+### Docker socket и безопасность
+
+- Монтирование `/var/run/docker.sock` даёт updater права на Docker API хоста: остановка, удаление и создание контейнеров, pull от имени хоста.
+- Ограничение по меткам снижает риск случайного пересоздания чужих контейнеров, но **не** изолирует updater от остального Docker на хосте.
+- Учётные данные registry передавайте через env, не коммитьте в git.
+- Блокировка цели: `/state/locks/`; при занятом lock цикл для цели пропускается.
+- При ошибке цикла пауза удваивается (`2 × POLL_INTERVAL_SECONDS`) с джиттером до +10%.
+
+### Валидация и staging
+
+```bash
+bash deploy/updater/validate.sh
+```
+
+Чеклист сценариев на staging: `deploy/updater/test-staging.sh`.
+
+## Первичная настройка сервера
+
+Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
+
+1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
+2. Проверить:
+
+```bash
+docker version
+docker compose version
+```
+
+3. Открыть входящие порты **3000** (frontend) и **8000** (backend) в firewall или настроить внешний доступ согласно вашей схеме (reverse proxy в репозитории не описан).
+4. Войти в registry:
+
+```bash
+docker login git.shx.one
+```
+
+5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
+6. Задать переменные окружения для compose (`CORS_ORIGIN`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` при приватных образах).
+7. Подтянуть образы и запустить стек:
+
+```bash
+cd deploy
+docker compose pull
+docker compose up -d
+```
+
+8. Проверить health (см. таблицу выше) и логи updater:
+
+```bash
+docker logs mmapp-updater --tail 50
+```
+
+9. В UI включить режим **live** и указать URL бэкенда, доступный браузеру пользователя; убедиться, что значение совпадает с `CORS_ORIGIN` на backend.
+
+## Эксплуатация и сопровождение
+
+### Автообновление
+
+Updater опрашивает registry с интервалом `POLL_INTERVAL_SECONDS` (по умолчанию 300 с). Обновление привязано к смене digest у образа из `targets.json` (часто тег `:latest` после push в `main`).
+
+### Ручное обновление
+
+```bash
+docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
+docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
+```
+
+Через compose (пересоздание при смене образа):
+
+```bash
+cd deploy
+docker compose pull
+docker compose up -d --force-recreate
+```
+
+Пинning версии: тег `:` из CI вместо `:latest` в `image`, метке `mmapp.updater.image` и в `targets.json`.
+
+### Откат
+
+- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
+- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
+
+### Резервное копирование PostgreSQL
+
+Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
+
+```bash
+docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
+```
+
+Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
+
+Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
+
+### Отладка
+
+| Действие | Команда |
+|----------|---------|
+| Логи сервиса | `docker logs mmapp-backend`, `docker logs mmapp-frontend`, `docker logs mmapp-updater` |
+| Следить в реальном времени | `docker logs -f mmapp-updater` |
+| Конфигурация контейнера | `docker inspect mmapp-backend` |
+| Состояние updater | том `updater-state`, файл `/state/updater-state.json` внутри контейнера |
+
+## Чеклист деплоя
+
+1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
+2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
+3. Выполнить `docker login git.shx.one`.
+4. Склонировать репозиторий или скопировать `deploy/`.
+5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
+6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
+7. Создать `deploy/updater/targets.json` из example; настроить health URL для сети хоста.
+8. Обновить bind-mount targets в `deploy/docker-compose.yml` на `./updater/targets.json`.
+9. Выполнить `docker compose pull` и `docker compose up -d` в каталоге `deploy`.
+10. Проверить `curl` на `:8000/health` и `:3000/`.
+11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
+12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
+
+### Автоматизация NetFlow через Docker-агент
+
+[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx
new file mode 100644
index 00000000..5bd992aa
--- /dev/null
+++ b/app/(main)/alerts/page.tsx
@@ -0,0 +1,2833 @@
+"use client"
+
+import type {AlertsResponse} from "@mmapp/contracts/alerts"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import {can} from "@/lib/auth"
+import {useAlertsTelegramPreview} from "@/hooks/use-alerts-telegram-preview"
+import {useAlertsConfigWrite} from "@/hooks/use-alerts-config-write"
+import {AlertsGroupForm} from "@/components/alerts-group-form"
+import {useAlertsTargets} from "@/hooks/use-alerts-targets"
+import {AlertsOutboxPanel} from "@/components/alerts-outbox-panel"
+import {AlertsTelegramForm} from "@/components/alerts-telegram-form"
+import {AlertsTargetsStatus} from "@/components/alerts-targets-status"
+import {AlertsTargetIssues} from "@/components/alerts-target-issues"
+import { useDataSource } from "@/lib/data-source"
+import { PageHeader } from "@/components/page-header"
+import { FormToggle } from "@/components/form-kit"
+import { OpsPanel } from "@/components/ops-panel"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { Separator } from "@/components/ui/separator"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {
+ Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
+} from "@/components/ui/sheet"
+import {
+ BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
+ CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
+ TimerIcon, WifiOffIcon, TrendingDownIcon,
+ SendIcon, CheckIcon,
+ Trash2Icon,
+ PencilIcon,
+ ChevronsUpDownIcon,
+ SearchIcon,
+ AlertCircleIcon,
+ ChevronUpIcon,
+ ChevronDownIcon,
+ PlugIcon,
+ UnplugIcon,
+} from "lucide-react"
+import { Flag, countryName } from "@/components/flag"
+import { cn } from "@/lib/utils"
+import {getAlerts} from "@/shared/api/alerts"
+
+// ─── types ────────────────────────────────────────────────────────────────────
+
+type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
+type AlertSeverity = "critical" | "warning" | "info"
+type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
+type RecoveryMode = "always" | "never" | "conditional"
+
+interface AlertRule {
+ id: string
+ name: string
+ type: AlertType
+ /** Сводная строка (как с бэкенда) */
+ target: string
+ /** Объекты правила (OR); если пусто — используется `target` */
+ targets: string[]
+ groupId: string | null
+ /** Сводная подпись условий в списке */
+ condition: string
+ /** Все условия (OR); при отсутствии — трактуем как `[condition]` */
+ conditions: string[]
+ severity: AlertSeverity
+ enabled: boolean
+ cooldown: AlertCooldown
+ /** Секунды: уведомление только если условие не пропало всё это время; null — выкл. */
+ confirmStabilitySec: number | null
+ recoveryMode?: RecoveryMode
+ recoveryStabilitySec?: number | null
+ lastFired: string | null // relative time string
+ chatId: string // override; empty = use global
+}
+
+interface AlertGroup {
+ id: string
+ name: string
+ combineMode: "any" | "all"
+ enabled: boolean
+ cooldownOverride: AlertCooldown | null
+}
+
+interface HistoryEntry {
+ id: string
+ ruleName: string
+ severity: AlertSeverity
+ message: string
+ time: string
+ sent: boolean
+ source?:"db"|"live"
+}
+
+interface TelegramConfig {
+ token: string
+ chatId: string
+ /** Положительное число — тема супергруппы (forum), Bot API: `message_thread_id` */
+ messageThreadId: string
+ connected: boolean
+}
+
+interface AlertsApiRule {
+ id: string
+ name: string
+ type: AlertType
+ target: string
+ targets?: string[]
+ groupId?: string | null
+ condition: string
+ conditions?: string[]
+ severity: AlertSeverity
+ enabled: boolean
+ cooldown: AlertCooldown
+ confirmStabilitySec?: number | null
+ recoveryMode?: RecoveryMode
+ recoveryStabilitySec?: number | null
+ chatId: string
+ lastFiredAt: string | null
+}
+
+interface AlertsApiGroup {
+ id: string
+ name: string
+ combineMode: "any" | "all"
+ enabled: boolean
+ cooldownOverride: AlertCooldown | null
+}
+
+type AlertsApiHistoryEntry=AlertsResponse["history"][number]
+
+interface AlertsServerRow {
+ name: string
+ host: string
+ site: string
+ country: string
+ type: string
+}
+
+/** Каталог GRE для карточек объекта; `targetLabel` = значение `target` в правиле. */
+interface AlertsGreTunnelRow {
+ targetLabel: string
+ tunnelName: string
+ routerName: string
+ country: string
+ site: string
+ remoteAddress: string
+ status: "up" | "down" | "degraded" | string
+ comment?: string
+}
+
+interface AlertsMeta {
+ targetIssues?: AlertsResponse["meta"]["targetIssues"]
+ servers: string[]
+ greClients: string[]
+ probeTargets: string[]
+ rttLossTargets: string[]
+ trafficServers: string[]
+ /** Каталог для карточек «Объект» (сервер / GRE-клиент / трафик) */
+ serversDetail?: AlertsServerRow[]
+ /** Собирается на клиенте из `/api/bgp/sessions` в live-режиме */
+ bgpPeers?: string[]
+ /** Подписи «имя туннеля / сервер» из `/api/filters/gre-tunnels` + `/api/servers` (как на /gre) */
+ greTunnelTargets?: string[]
+ /** Расширенные поля для карточек выбора туннеля */
+ greTunnelDetail?: AlertsGreTunnelRow[]
+}
+
+
+
+function formatRelativeRu(iso: string): string {
+ const t = Date.parse(iso)
+ if (!Number.isFinite(t)) return iso
+ const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
+ if (sec < 45) return "только что"
+ if (sec < 3600) return `${Math.max(1, Math.floor(sec / 60))} мин назад`
+ if (sec < 86400) return `${Math.max(1, Math.floor(sec / 3600))} ч назад`
+ const d = Math.floor(sec / 86400)
+ return `${d} д назад`
+}
+
+function uniqStrings(xs: string[]): string[] {
+ const o: string[] = []
+ const s = new Set()
+ for (const x of xs) {
+ const k = x.trim()
+ if (!k || s.has(k)) continue
+ s.add(k)
+ o.push(k)
+ }
+ return o
+}
+
+/** Сводка условий для колонки `condition` в API / списке правил. */
+function summarizeConditionsUi(lines: string[]): string {
+ const u = uniqStrings(lines.map((t) => t.trim()).filter(Boolean))
+ if (u.length === 0) return "—"
+ if (u.length === 1) return u[0]!
+ if (u.length <= 3) return u.join(" · ")
+ return `${u[0]} · ${u[1]} · +${u.length - 2}`
+}
+
+function mapApiRules(rows: AlertsApiRule[]): AlertRule[] {
+ return rows.map((r) => {
+ const targets = r.targets?.length ? r.targets : r.target.trim() ? [r.target.trim()] : []
+ const conditions =
+ r.conditions && r.conditions.length > 0
+ ? r.conditions.map((c) => c.trim()).filter(Boolean)
+ : r.condition.trim()
+ ? [r.condition.trim()]
+ : []
+ return {
+ id: r.id,
+ name: r.name,
+ type: r.type,
+ target: r.target,
+ targets,
+ groupId: r.groupId ?? null,
+ condition: r.condition,
+ conditions,
+ severity: r.severity,
+ enabled: r.enabled,
+ cooldown: r.cooldown,
+ confirmStabilitySec:
+ r.confirmStabilitySec != null && r.confirmStabilitySec > 0 ? r.confirmStabilitySec : null,
+ recoveryMode: r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always",
+ recoveryStabilitySec:
+ r.recoveryStabilitySec != null && r.recoveryStabilitySec > 0 ? r.recoveryStabilitySec : null,
+ chatId: r.chatId ?? "",
+ lastFired: r.lastFiredAt ? formatRelativeRu(r.lastFiredAt) : null,
+ }
+ })
+}
+
+function mapApiGroups(rows: AlertsApiGroup[]): AlertGroup[] {
+ return rows.map((g) => ({
+ id: g.id,
+ name: g.name,
+ combineMode: g.combineMode,
+ enabled: g.enabled,
+ cooldownOverride: g.cooldownOverride,
+ }))
+}
+
+function mapApiHistory(rows: AlertsApiHistoryEntry[]): HistoryEntry[] {
+ return rows.map((e) => ({
+ id: e.id,
+ ruleName: e.ruleName,
+ severity: e.severity,
+ message: e.message,
+ time: formatRelativeRu(e.firedAt),
+ sent: e.sent, source:e.source,
+ }))
+}
+
+function toApiRuleBody(r: AlertRule) {
+ const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : []
+ const conditions =
+ r.conditions.length > 0
+ ? r.conditions.map((c) => c.trim()).filter(Boolean)
+ : r.condition.trim()
+ ? [r.condition.trim()]
+ : []
+ return {
+ id: r.id,
+ name: r.name,
+ type: r.type,
+ target: targets[0] ?? r.target,
+ targets,
+ groupId: r.groupId ?? null,
+ condition: conditions[0] ?? r.condition,
+ conditions,
+ severity: r.severity,
+ enabled: r.enabled,
+ cooldown: r.cooldown,
+ confirmStabilitySec: r.confirmStabilitySec ?? null,
+ recoveryMode: r.recoveryMode ?? "always",
+ recoveryStabilitySec: r.recoveryStabilitySec ?? null,
+ chatId: r.chatId ?? "",
+ }
+}
+
+function toApiGroupBody(g: AlertGroup) {
+ return {
+ id: g.id,
+ name: g.name,
+ combineMode: g.combineMode,
+ enabled: g.enabled,
+ cooldownOverride: g.cooldownOverride,
+ }
+}
+
+/** Снимок полей формы правила (без id / lastFired / enabled). */
+interface RuleFormSnapshot {
+ name: string
+ type: AlertType
+ targets: string[]
+ groupId: string | null
+ /** Шаблоны из TYPE_CONDITIONS (для типов с порогом — с «порога») */
+ conditions: string[]
+ threshold: string
+ severity: AlertSeverity
+ cooldown: AlertCooldown
+ /** null — без задержки подтверждения */
+ confirmStabilitySec: number | null
+ recoveryMode?: RecoveryMode
+ recoveryStabilitySec?: number | null
+ chatId: string
+}
+
+function inferThresholdConditionTemplate(type: AlertType, line: string): string {
+ const t = line.trim().toLowerCase()
+ if (type === "rtt") return t.includes("<") ? "< порога" : "> порога"
+ if (type === "loss") return "> порога"
+ if (type === "traffic") return /tx\s* 0
+ ? [...r.conditions]
+ : r.condition.trim()
+ ? [r.condition.trim()]
+ : []
+ let threshold = ""
+ let conditions: string[] = []
+ if (unit && (r.type === "rtt" || r.type === "loss" || r.type === "traffic")) {
+ for (const line of rawConds) {
+ const m = line.match(/(\d+(?:[.,]\d+)?)/)
+ if (m && !threshold) threshold = m[1]!.replace(",", ".")
+ conditions.push(inferThresholdConditionTemplate(r.type, line))
+ }
+ conditions = uniqStrings(conditions)
+ } else {
+ conditions = uniqStrings(rawConds.map((x) => x.trim()).filter(Boolean))
+ }
+ const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : []
+ return {
+ name: r.name,
+ type: r.type,
+ targets,
+ groupId: r.groupId ?? null,
+ conditions,
+ threshold,
+ severity: r.severity,
+ cooldown: r.cooldown,
+ confirmStabilitySec: r.confirmStabilitySec ?? null,
+ recoveryMode: r.recoveryMode ?? "always",
+ recoveryStabilitySec: r.recoveryStabilitySec ?? null,
+ chatId: r.chatId ?? "",
+ }
+}
+
+/** Предлагаемое название по типу, объекту и условию. */
+function suggestAlertRuleName(
+ type: AlertType,
+ targets: string[],
+ conditions: string[],
+ threshold: string,
+): string {
+ const typeLabel = TYPE_META[type].label
+ const tgt =
+ targets.length === 0
+ ? "…"
+ : targets.length === 1
+ ? targets[0]!.trim() || "…"
+ : `${targets.length} объектов`
+ const unit = THRESHOLD_UNIT[type]
+ let cond =
+ conditions.length === 0
+ ? "…"
+ : conditions.length === 1
+ ? conditions[0]!.trim() || "…"
+ : `${conditions.length} условий`
+ if (unit && threshold.trim() && cond.includes("порога")) {
+ cond = cond.replace("порога", `${threshold.trim()} ${unit}`)
+ }
+ const base = `${typeLabel}: ${tgt} — ${cond}`
+ return base.length > 110 ? `${base.slice(0, 107)}…` : base
+}
+
+function emptyTypeTargets(): Record {
+ return {
+ "gre-tunnel": [],
+ "bgp-peer": [],
+ "bgp-prefix": [],
+ "gre-client": [],
+ server: [],
+ rtt: [],
+ loss: [],
+ traffic: [],
+ }
+}
+
+/** Live: только данные из meta (и bgpPeers), без моковых подписей. */
+function buildLiveTypeTargets(meta: AlertsMeta): Record {
+ const srv = uniqStrings(meta.servers ?? [])
+ const bgp = uniqStrings(meta.bgpPeers ?? [])
+ const probes = uniqStrings(meta.probeTargets ?? [])
+ const rttLoss = meta.rttLossTargets.length ? uniqStrings(meta.rttLossTargets) : probes
+ const traffic = meta.trafficServers.length ? uniqStrings(meta.trafficServers) : srv
+ const greClients = uniqStrings([...(meta.greClients ?? []), ...srv])
+ const greTunnels = uniqStrings(meta.greTunnelTargets ?? [])
+ return {
+ "gre-tunnel": greTunnels,
+ "bgp-peer": bgp,
+ "bgp-prefix": [],
+ "gre-client": greClients,
+ server: srv,
+ rtt: rttLoss,
+ loss: rttLoss,
+ traffic,
+ }
+}
+
+const ALERT_TYPES_ORDER: AlertType[] = [
+ "server",
+ "bgp-peer",
+ "bgp-prefix",
+ "gre-tunnel",
+ "gre-client",
+ "rtt",
+ "loss",
+ "traffic",
+]
+
+const TYPE_EVENT_CARD_DESC: Record = {
+ "gre-tunnel": "Смена состояния GRE-туннеля между узлами",
+ "bgp-peer": "Разрыв или восстановление BGP-сессии",
+ "bgp-prefix": "Появление или отзыв префикса",
+ "gre-client": "Подключение или отключение GRE-клиента",
+ "server": "Доступность и деградация сервера в каталоге",
+ "rtt": "Порог задержки по ping-пробе",
+ "loss": "Порог потерь по ping-пробе",
+ "traffic": "Низкий RX/TX по данным сбора трафика",
+}
+
+// ─── static config ────────────────────────────────────────────────────────────
+
+const TYPE_META: Record
+ label: string
+ iconClass: string
+ bg: string
+}> = {
+ "gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" },
+ "bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" },
+ "bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" },
+ "gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" },
+ "server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" },
+ "rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" },
+ "loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" },
+ "traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" },
+}
+
+const SEVERITY_META: Record = {
+ critical: {
+ label: "Критическое",
+ dot: "bg-red-500",
+ badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
+ chip: "border-red-400 bg-red-500/10 text-red-700 dark:text-red-400",
+ },
+ warning: {
+ label: "Предупреждение",
+ dot: "bg-amber-400",
+ badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
+ chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400",
+ },
+ info: {
+ label: "Информационное",
+ dot: "bg-blue-500",
+ badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
+ chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400",
+ },
+}
+
+const TYPE_TARGETS: Record = {
+ "gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"],
+ "bgp-peer": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
+ "bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"],
+ "gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"],
+ "server": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
+ "rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
+ "loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
+ "traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
+}
+
+function splitGreTargetLabel(label: string): { tunnel: string; router: string } {
+ const i = label.indexOf(" / ")
+ if (i > 0) {
+ return { tunnel: label.slice(0, i).trim(), router: label.slice(i + 3).trim() }
+ }
+ return { tunnel: label.trim(), router: "" }
+}
+
+function demoGreTunnelCatalog(): AlertsGreTunnelRow[] {
+ const sites = ["MSK", "SPB", "FRA", "AMS", "SGP"] as const
+ const countries = ["RU", "RU", "DE", "NL", "SG"] as const
+ return TYPE_TARGETS["gre-tunnel"].map((label, idx) => {
+ const { tunnel, router } = splitGreTargetLabel(label)
+ return {
+ targetLabel: label,
+ tunnelName: tunnel,
+ routerName: router || "—",
+ country: countries[idx % countries.length] ?? "RU",
+ site: sites[idx % sites.length] ?? "MSK",
+ remoteAddress: `10.${(idx % 220) + 1}.0.2`,
+ status: idx % 6 === 3 ? "degraded" : idx % 6 === 5 ? "down" : "up",
+ }
+ })
+}
+
+const GRE_CARD_STATUS: Record = {
+ up: { label: "Поднят", dot: "bg-emerald-500" },
+ degraded: { label: "Деградация", dot: "bg-amber-500" },
+ down: { label: "Offline", dot: "bg-red-500" },
+}
+
+/** ISO 3166-1 alpha-2 для `Flag`; иначе null. */
+function iso2CountryCode(country: string): string | null {
+ const t = country.replace(/\s/g, "").toUpperCase()
+ if (!t || t === "—" || t === "-") return null
+ if (t.length === 2 && /^[A-Z]{2}$/.test(t)) return t
+ return null
+}
+
+/** Вторая строка в сводке / подсказка на карточке для типов без серверного/GRE-каталога. */
+function discreteTargetSubtitle(type: AlertType, target: string): string | null {
+ const t = target.trim()
+ if (!t) return null
+ if (type === "bgp-peer") {
+ const i = t.indexOf(":")
+ if (i > 0) {
+ const peer = t.slice(i + 1).trim()
+ if (peer) return `Сосед: ${peer}`
+ }
+ }
+ if (type === "rtt" || type === "loss") {
+ const i = t.indexOf("→")
+ if (i > 0) {
+ const right = t.slice(i + 1).trim()
+ if (right) return `Цель: ${right}`
+ }
+ }
+ return null
+}
+
+/** Иконка строки условия (согласована с текстом варианта). */
+function conditionLeadingIcon(condition: string): {
+ Icon: React.FC<{ className?: string }>
+ className: string
+} {
+ const c = condition.toLowerCase()
+ if (c.includes("разорвал")) return { Icon: UnplugIcon, className: "text-red-400/90" }
+ if (c.includes("восстановил сесс")) return { Icon: PlugIcon, className: "text-emerald-500/90" }
+ if (c.includes("offline") || c.includes("отключ")) return { Icon: WifiOffIcon, className: "text-red-400/90" }
+ if (c.includes("отозван")) return { Icon: RouteIcon, className: "text-orange-400/90" }
+ if (c.includes("получен")) return { Icon: RouteIcon, className: "text-sky-400/90" }
+ if (c.includes("восстанов") || c.includes("подключ")) return { Icon: CheckIcon, className: "text-emerald-500/90" }
+ if (c.includes("degraded")) return { Icon: AlertCircleIcon, className: "text-amber-500/90" }
+ if (c.includes(">")) return { Icon: ChevronUpIcon, className: "text-amber-500/90" }
+ if (c.includes("<")) return { Icon: ChevronDownIcon, className: "text-sky-400/90" }
+ return { Icon: BellIcon, className: "text-muted-foreground" }
+}
+
+const TYPE_CONDITIONS: Record = {
+ "gre-tunnel": ["перешёл в offline", "восстановился"],
+ "bgp-peer": ["разорвал сессию", "восстановил сессию"],
+ "bgp-prefix": ["был отозван", "был получен"],
+ "gre-client": ["отключился", "подключился"],
+ "server": ["перешёл в offline", "перешёл в degraded", "восстановился"],
+ "rtt": ["> порога", "< порога"],
+ "loss": ["> порога"],
+ "traffic": ["RX < порога", "TX < порога"],
+}
+
+const THRESHOLD_UNIT: Partial> = {
+ rtt: "мс",
+ loss: "%",
+ traffic: "Мбит/с",
+}
+
+const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"]
+
+/** Задержка подтверждения: уведомление только если условие держится без «отмены» (пропало срабатывание). */
+const CONFIRM_STABILITY_OPTIONS: { value: number | null; label: string }[] = [
+ { value: null, label: "Нет" },
+ { value: 30, label: "30 с" },
+ { value: 60, label: "1 мин" },
+ { value: 120, label: "2 мин" },
+ { value: 300, label: "5 мин" },
+ { value: 900, label: "15 мин" },
+ { value: 3600, label: "1 ч" },
+]
+
+function formatConfirmStabilityShort(sec: number | null | undefined): string | null {
+ if (sec == null || sec <= 0) return null
+ const o = CONFIRM_STABILITY_OPTIONS.find((x) => x.value === sec)
+ return o?.label ?? `${sec} с`
+}
+
+// ─── mock data ────────────────────────────────────────────────────────────────
+
+const INIT_RULES: AlertRule[] = [
+ { id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", targets: ["gre-ivanov-01 / core-01"], groupId: null, condition: "перешёл в offline", conditions: ["перешёл в offline"], severity: "critical", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: "3ч назад", chatId: "" },
+ { id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01: uplink-1", targets: ["mt-msk-core-01: uplink-1"], groupId: null, condition: "разорвал сессию", conditions: ["разорвал сессию"], severity: "critical", enabled: true, cooldown: "15м", confirmStabilitySec: null, lastFired: "1д назад", chatId: "" },
+ { id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", targets: ["mt-fra-edge-01 → 8.8.8.8"], groupId: null, condition: "> 120 мс", conditions: ["> 120 мс"], severity: "warning", enabled: true, cooldown: "15м", confirmStabilitySec: null, lastFired: "45м назад", chatId: "" },
+ { id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", targets: ["mt-ams-edge-01 → 1.1.1.1"], groupId: null, condition: "> 5%", conditions: ["> 5%"], severity: "warning", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: null, chatId: "" },
+ { id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", targets: ["mt-sgp-edge-01"], groupId: null, condition: "перешёл в offline", conditions: ["перешёл в offline"], severity: "critical", enabled: true, cooldown: "1ч", confirmStabilitySec: null, lastFired: "2д назад", chatId: "" },
+ { id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", targets: ["любой префикс"], groupId: null, condition: "был отозван", conditions: ["был отозван"], severity: "info", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: "6ч назад", chatId: "-1009876543210" },
+ { id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", targets: ["gre-kozlov-01"], groupId: null, condition: "отключился", conditions: ["отключился"], severity: "warning", enabled: false, cooldown: "1ч", confirmStabilitySec: null, lastFired: null, chatId: "" },
+ { id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", targets: ["mt-ams-edge-01"], groupId: null, condition: "RX < 10 Мбит/с", conditions: ["RX < 10 Мбит/с"], severity: "info", enabled: false, cooldown: "4ч", confirmStabilitySec: null, lastFired: null, chatId: "" },
+]
+
+const INIT_GROUPS: AlertGroup[] = []
+
+const INIT_HISTORY: HistoryEntry[] = [
+ { id: "h1", ruleName: "Высокая задержка FRA", severity: "warning", message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)", time: "45м назад", sent: true },
+ { id: "h2", ruleName: "GRE-туннель Иванов offline", severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline", time: "3ч назад", sent: true },
+ { id: "h3", ruleName: "BGP-префикс отозван core-01", severity: "info", message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01", time: "6ч назад", sent: true },
+ { id: "h4", ruleName: "BGP-сосед core-01 down", severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана", time: "1д назад", sent: true },
+ { id: "h5", ruleName: "Сервер SGP offline", severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout", time: "2д назад", sent: false },
+]
+
+type AlertPresetDef = {
+ id: string
+ title: string
+ description: string
+ build: (serverNames: string[]) => Omit[]
+}
+
+const ALERT_PRESETS: AlertPresetDef[] = [
+ {
+ id: "srv-offline-pack",
+ title: "Offline по серверам",
+ description: "По одному правилу на каждый сервер из каталога (до 24 шт.).",
+ build: (serverNames) =>
+ serverNames.slice(0, 24).map((name) => ({
+ name: `Сервер ${name}: offline`,
+ type: "server" as const,
+ target: name,
+ targets: [name],
+ groupId: null,
+ condition: "перешёл в offline",
+ conditions: ["перешёл в offline"],
+ severity: "critical" as const,
+ cooldown: "5м" as const,
+ confirmStabilitySec: null,
+ chatId: "",
+ })),
+ },
+ {
+ id: "rtt-8888-pack",
+ title: "RTT → 8.8.8.8",
+ description: "До 6 правил: задержка выше 100 мс для первых серверов.",
+ build: (serverNames) =>
+ serverNames.slice(0, 6).map((name) => ({
+ name: `RTT ${name}`,
+ type: "rtt" as const,
+ target: name,
+ targets: [name],
+ groupId: null,
+ condition: "> 100 мс",
+ conditions: ["> 100 мс"],
+ severity: "warning" as const,
+ cooldown: "15м" as const,
+ confirmStabilitySec: null,
+ chatId: "",
+ })),
+ },
+]
+
+const INIT_TG: TelegramConfig = {
+ token: "",
+ chatId: "",
+ messageThreadId: "",
+ connected: false,
+}
+
+// ─── small helpers ────────────────────────────────────────────────────────────
+
+function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
+ return {children}
+}
+
+function FieldHint({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+function SeverityDot({ severity }: { severity: AlertSeverity }) {
+ return
+}
+
+function SeverityBadge({ severity }: { severity: AlertSeverity }) {
+ return (
+
+ {severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
+
+ )
+}
+
+// ─── alert rule row ───────────────────────────────────────────────────────────
+
+function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled,isLive }: {
+ rule: AlertRule
+ onToggle: (id: string, enabled: boolean) => void
+ onDelete: (id: string) => void
+ onEdit?: (id: string) => void
+ interactionsDisabled?: boolean
+ isLive:boolean
+}) {
+ const { Icon, iconClass, bg } = TYPE_META[rule.type]
+
+ return (
+
+ {/* toggle */}
+
onToggle(rule.id, v)} disabled={interactionsDisabled||(isLive&&rule.type==="bgp-prefix"&&!rule.enabled)} />
+
+ {/* severity dot */}
+
+
+ {/* type icon */}
+
+
+
+
+ {/* name + target */}
+
+ {isLive&&rule.type==="bgp-prefix"?
Отслеживание появления и отзыва префиксов пока недоступно. Это правило не проверяется.
:null}
+
{rule.name}
+
+ {TYPE_META[rule.type].label} ·{" "}
+
+ {rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
+
+ {rule.groupId ? (
+ · группа
+ ) : null}
+ {" · "}{rule.condition}
+
+
+
+ {/* cooldown */}
+
+ {rule.cooldown}
+
+ {formatConfirmStabilityShort(rule.confirmStabilitySec) ? (
+
+ ⏱ {formatConfirmStabilityShort(rule.confirmStabilitySec)}
+
+ ) : null}
+
+ {/* chat override badge */}
+ {rule.chatId && (
+
+ #{rule.chatId.slice(-6)}
+
+ )}
+
+ {/* last fired */}
+
+ {rule.lastFired ?? "—"}
+
+
+ {/* severity badge */}
+
+
+
+
+ {/* actions (visible on hover) */}
+
+ {onEdit && (
+
onEdit(rule.id)}
+ className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
+ title="Изменить" aria-label={"Изменить правило "+rule.name}
+ >
+
+
+ )}
+
onDelete(rule.id)}
+ className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40">
+
+
+
+
+ )
+}
+
+// ─── telegram config card ─────────────────────────────────────────────────────
+
+// ─── history card ─────────────────────────────────────────────────────────────
+
+function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
+ return (
+
+ {entries.length === 0 ? (
+ Нет срабатываний
+ ) : (
+
+ {entries.map(e => (
+
+
+
+
{e.ruleName}
+
{e.message}
+
+
+
{e.time}
+
+ {e.source==="live"?"Мониторинг":!e.source?"Демо":e.sent?"API принял":"Не подтверждено"}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+const SERVER_ROLES_RU: Record = {
+ "jump-host": "Транзитный узел",
+ "exit-node": "Выходной узел",
+ "home-router": "Домашний роутер",
+}
+
+function serverRowLabel(s: AlertsServerRow): string {
+ return (s.name?.trim() || s.host || "").trim()
+}
+
+/** Типы, где «объект» — сервер из каталога: показываем развёрнутые карточки. */
+const OBJECT_TYPES_WITH_SERVER_CARDS: AlertType[] = ["server", "gre-client", "traffic"]
+
+/** Карточка сервера: поиск, флаг страны, имя, host, площадка, роль (как GRE-карточки). */
+function ServerObjectCardPicker({
+ label,
+ rows,
+ selected,
+ onToggle,
+ hideLabel,
+}: {
+ label: string
+ rows: AlertsServerRow[]
+ selected: string[]
+ onToggle: (serverTitle: string) => void
+ /** Один общий заголовок снаружи (блок «шагов») */
+ hideLabel?: boolean
+}) {
+ const [query, setQuery] = useState("")
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setQuery("")
+ })
+ }, [rows])
+
+ const filteredRows = useMemo(() => {
+ const s = query.trim().toLowerCase()
+ if (!s) return rows
+ return rows.filter((row) => {
+ const cc = iso2CountryCode(row.country)
+ const blob = [
+ serverRowLabel(row),
+ row.host,
+ row.site,
+ row.country,
+ cc ? countryName(cc) : "",
+ SERVER_ROLES_RU[row.type] ?? row.type,
+ ]
+ .join(" ")
+ .toLowerCase()
+ return blob.includes(s)
+ })
+ }, [rows, query])
+
+ return (
+
+ {!hideLabel ?
{label} : null}
+ {rows.length === 0 ? (
+
Нет серверов для выбора
+ ) : (
+ <>
+
+
+ setQuery(e.target.value)}
+ autoComplete="off"
+ name="alert-server-search"
+ placeholder="Поиск: сервер, площадка, IP, роль…"
+ className="h-8 pl-8 pr-2 text-xs"
+ aria-label="Поиск по списку серверов"
+ />
+
+
+ {filteredRows.length === 0 ? (
+
Ничего не найдено
+ ) : null}
+ {filteredRows.map((s) => {
+ const title = serverRowLabel(s)
+ const active = selected.includes(title)
+ const loc = [s.site, s.country].filter(Boolean).join(" · ")
+ const cc = iso2CountryCode(s.country)
+ return (
+
onToggle(title)}
+ className={cn(
+ "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
+ active
+ ? "border-primary bg-primary/8 ring-1 ring-primary/30"
+ : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
+ )}
+ >
+
+
+
+
+
+ {cc ? (
+
+ ) : null}
+ {title}
+
+
{s.host || "—"}
+ {loc ? (
+
{loc}
+ ) : null}
+
+ {SERVER_ROLES_RU[s.type] ?? s.type}
+
+
+
+ {active ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
+
+/** Карточки GRE: страна/площадка, роутер, имя интерфейса, remote, статус. */
+function GreTunnelObjectCardPicker({
+ label,
+ rows,
+ selected,
+ onToggle,
+ hideLabel,
+}: {
+ label: string
+ rows: AlertsGreTunnelRow[]
+ selected: string[]
+ onToggle: (targetLabel: string) => void
+ hideLabel?: boolean
+}) {
+ const [query, setQuery] = useState("")
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setQuery("")
+ })
+ }, [rows])
+
+ const filteredRows = useMemo(() => {
+ const s = query.trim().toLowerCase()
+ if (!s) return rows
+ return rows.filter((r) => {
+ const cc = iso2CountryCode(r.country)
+ const hay = [
+ r.tunnelName,
+ r.routerName,
+ r.site,
+ r.country,
+ r.remoteAddress,
+ r.targetLabel,
+ r.comment ?? "",
+ cc ? countryName(cc) : "",
+ ]
+ .join(" ")
+ .toLowerCase()
+ return hay.includes(s)
+ })
+ }, [rows, query])
+
+ return (
+
+ {!hideLabel ?
{label} : null}
+ {rows.length === 0 ? (
+
Нет туннелей для выбора
+ ) : (
+ <>
+
+
+ setQuery(e.target.value)}
+ autoComplete="off"
+ name="alert-gre-search"
+ placeholder="Поиск: имя, роутер, IP, площадка…"
+ className="h-8 pl-8 pr-2 text-xs"
+ aria-label="Поиск по списку GRE-туннелей"
+ />
+
+
+ {filteredRows.length === 0 ? (
+
Ничего не найдено
+ ) : null}
+ {filteredRows.map((r) => {
+ const active = selected.includes(r.targetLabel)
+ const st = GRE_CARD_STATUS[r.status] ?? { label: String(r.status), dot: "bg-muted-foreground" }
+ const cc = iso2CountryCode(r.country)
+ const siteLine =
+ r.site && r.site !== "—"
+ ? r.site
+ : cc
+ ? countryName(cc)
+ : "—"
+ return (
+
onToggle(r.targetLabel)}
+ className={cn(
+ "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
+ active
+ ? "border-primary bg-primary/8 ring-1 ring-primary/30"
+ : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
+ )}
+ >
+
+
+
+
+
+ {cc ? (
+
+ ) : null}
+ {siteLine}
+
+
+ Роутер {" "}
+ {r.routerName}
+
+
+ Название {" "}
+ {r.tunnelName}
+
+ {r.remoteAddress && r.remoteAddress !== "—" ? (
+
+ {r.remoteAddress}
+
+ ) : null}
+
+
+
+
+
+ {st.label}
+
+ {active ? (
+
+ ) : null}
+
+
+ {r.comment ? (
+
+ {r.comment}
+
+ ) : null}
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
+
+/** Карточки объекта для типов со строковым списком (BGP, префиксы, RTT/loss и т.д.): поиск + иконка типа. */
+function StringOptionCardPicker({
+ label,
+ options,
+ selected,
+ onToggle,
+ hideLabel,
+ ruleType,
+}: {
+ label: string
+ options: string[]
+ selected: string[]
+ onToggle: (opt: string) => void
+ hideLabel?: boolean
+ ruleType: AlertType
+}) {
+ const [query, setQuery] = useState("")
+ const m = TYPE_META[ruleType]
+ const Icon = m.Icon
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setQuery("")
+ })
+ }, [options])
+
+ const filtered = useMemo(() => {
+ const s = query.trim().toLowerCase()
+ if (!s) return options
+ return options.filter((o) => o.toLowerCase().includes(s))
+ }, [options, query])
+
+ return (
+
+ {!hideLabel ?
{label} : null}
+ {options.length === 0 ? (
+
Нет объектов для выбора
+ ) : (
+ <>
+
+
+ setQuery(e.target.value)}
+ autoComplete="off"
+ name="alert-target-search"
+ placeholder="Поиск по списку объектов…"
+ className="h-8 pl-8 pr-2 text-xs"
+ aria-label="Поиск по списку объектов"
+ />
+
+
+ {filtered.length === 0 ? (
+
Ничего не найдено
+ ) : null}
+ {filtered.map((opt) => {
+ const active = selected.includes(opt)
+ const hint = discreteTargetSubtitle(ruleType, opt)
+ return (
+
onToggle(opt)}
+ className={cn(
+ "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
+ active
+ ? "border-primary bg-primary/8 ring-1 ring-primary/30"
+ : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
+ )}
+ >
+
+
+
+
+
{opt}
+ {hint ? (
+
{hint}
+ ) : null}
+
+
+ {active ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
+
+/** Выбор условия: как объекты — несколько карточек, OR по условиям. */
+function ConditionOptionCardPicker({
+ label,
+ conditions,
+ selected,
+ onToggle,
+ hideLabel,
+ ruleType,
+}: {
+ label: string
+ conditions: string[]
+ selected: string[]
+ onToggle: (v: string) => void
+ hideLabel?: boolean
+ ruleType: AlertType
+}) {
+ const [query, setQuery] = useState("")
+ const m = TYPE_META[ruleType]
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setQuery("")
+ })
+ }, [conditions])
+
+ const filtered = useMemo(() => {
+ const s = query.trim().toLowerCase()
+ if (!s) return conditions
+ return conditions.filter((o) => o.toLowerCase().includes(s))
+ }, [conditions, query])
+
+ return (
+
+ {!hideLabel ?
{label} : null}
+ {conditions.length === 0 ? (
+
Нет условий для этого типа
+ ) : (
+ <>
+
+
+ setQuery(e.target.value)}
+ autoComplete="off"
+ name="alert-condition-search"
+ placeholder="Поиск по вариантам условия…"
+ className="h-8 pl-8 pr-2 text-xs"
+ aria-label="Поиск по условиям"
+ />
+
+
+ {filtered.length === 0 ? (
+
Ничего не найдено
+ ) : null}
+ {filtered.map((opt) => {
+ const active = selected.includes(opt)
+ const { Icon: CondIcon, className: condIconCls } = conditionLeadingIcon(opt)
+ return (
+
onToggle(opt)}
+ className={cn(
+ "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
+ active
+ ? "border-primary bg-primary/8 ring-1 ring-primary/30"
+ : "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
+ )}
+ >
+
+
+
+
+
+
+ {active ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+ >
+ )}
+
+ )
+}
+
+/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */
+function ObjectPickSummaryRow({
+ ruleType,
+ targets,
+ server,
+ greTunnel,
+ onExpand,
+}: {
+ ruleType: AlertType
+ targets: string[]
+ server?: AlertsServerRow
+ greTunnel?: AlertsGreTunnelRow
+ onExpand: () => void
+}) {
+ const target = targets[0] ?? ""
+ const multiHint = targets.length > 1 ? ` (+${targets.length - 1})` : ""
+
+ if (greTunnel) {
+ const cc = iso2CountryCode(greTunnel.country)
+ const siteLine =
+ greTunnel.site && greTunnel.site !== "—"
+ ? greTunnel.site
+ : cc
+ ? countryName(cc)
+ : "—"
+ return (
+
+
+
+
+ {cc ? (
+
+ ) : null}
+
{siteLine}
+
+
+ {greTunnel.routerName}
+ ·
+ {greTunnel.tunnelName}
+
+
+
+
+ Изменить
+
+
+ )
+ }
+
+ if (server) {
+ const cc = iso2CountryCode(server.country)
+ const line2 = (() => {
+ const h = (server.host ?? "").trim()
+ if (h && h !== target.trim()) return { text: h, mono: true as const }
+ const loc = [server.site, server.country].filter(Boolean).join(" · ")
+ if (loc) return { text: loc, mono: false as const }
+ const role = SERVER_ROLES_RU[server.type] ?? server.type
+ if (role) return { text: role, mono: false as const }
+ return null
+ })()
+ return (
+
+
+
+
+ {cc ? (
+
+ ) : null}
+
{target}{multiHint}
+
+ {line2 ? (
+
+ {line2.text}
+
+ ) : null}
+
+
+
+ Изменить
+
+
+ )
+ }
+
+ const m = TYPE_META[ruleType]
+ const Icon = m.Icon
+ const sub = discreteTargetSubtitle(ruleType, target)
+ const joined = targets.length > 1 ? targets.join(", ") : target
+ return (
+
+
+
+
{joined}
+ {sub ? (
+
{sub}
+ ) : null}
+
+
+
+ Изменить
+
+
+ )
+}
+
+// ─── add rule sheet ───────────────────────────────────────────────────────────
+
+const BLANK_FORM: RuleFormSnapshot = {
+ name: "",
+ type: "server",
+ targets: [],
+ groupId: null,
+ conditions: [],
+ threshold: "",
+ severity: "warning",
+ cooldown: "5м",
+ confirmStabilitySec: null,
+ chatId: "",
+}
+
+type RuleTelegramTestPayload = {
+ name: string
+ targets: string[]
+ conditionLine: string
+ severity: AlertSeverity
+ cooldown: AlertCooldown
+ ruleChatId: string
+}
+
+function AddRuleSheet({
+ open,
+ onClose,
+ onSave,
+ typeTargets,
+ isLive,
+ serversCatalog,
+ greTunnelCatalog,
+ groups,
+ initialForm,
+ editRuleId,
+ onTestRuleTelegram,
+ testTelegramDisabled, saving,saveDisabled,saveError,review,onRefresh,testOutcome,testError,testReview,
+}: {
+ open: boolean
+ onClose: () => void
+ /** editRuleId задан — обновление существующего правила */
+ onSave: (rule: Omit, editRuleId: string | null) => Promise
+ saving:boolean
+ saveDisabled:boolean
+ saveError:string|null
+ onRefresh:()=>void
+ review:React.ReactNode
+ testOutcome:string|null
+ testError:string|null
+ testReview:React.ReactNode
+ typeTargets: Record
+ isLive: boolean
+ serversCatalog: AlertsServerRow[]
+ greTunnelCatalog: AlertsGreTunnelRow[]
+ groups: AlertGroup[]
+ initialForm: RuleFormSnapshot | null
+ editRuleId: string | null
+ /** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */
+ onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise
+ /** true — кнопка теста неактивна (например, бэкенд недоступен). */
+ testTelegramDisabled?: boolean
+}) {
+ const [form, setForm] = useState(() => ({
+ ...BLANK_FORM,
+ ...(initialForm ?? {}),
+ }))
+ const nameTouchedRef = useRef(Boolean(editRuleId))
+ /** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */
+ const [objectExpanded, setObjectExpanded] = useState(
+ () => !(initialForm?.targets?.length),
+ )
+ const [testTelegramBusy, setTestTelegramBusy] = useState(false)
+
+ const targets = typeTargets[form.type]
+ const conditions = TYPE_CONDITIONS[form.type]
+ const targetsEmpty = targets.length === 0
+
+ const objectServerCardRows = useMemo(() => {
+ if (!OBJECT_TYPES_WITH_SERVER_CARDS.includes(form.type)) return []
+ if (!serversCatalog.length) return []
+ const pick = new Set(targets)
+ return serversCatalog.filter((s) => pick.has(serverRowLabel(s)))
+ }, [form.type, serversCatalog, targets])
+
+ const objectGreCardRows = useMemo(() => {
+ if (form.type !== "gre-tunnel" || !greTunnelCatalog.length) return []
+ const pick = new Set(targets)
+ return greTunnelCatalog.filter((r) => pick.has(r.targetLabel))
+ }, [form.type, greTunnelCatalog, targets])
+
+ const useGreTunnelObjectCards = !targetsEmpty && objectGreCardRows.length > 0
+ const useServerObjectCards = !targetsEmpty && objectServerCardRows.length > 0
+ const unit = THRESHOLD_UNIT[form.type]
+ const allowManualTarget = targetsEmpty
+ const targetOk = allowManualTarget ? Boolean(form.targets[0]?.trim()) : form.targets.length > 0
+ const hasObject = form.targets.length > 0
+
+ const objectSummaryServer = useMemo((): AlertsServerRow | undefined => {
+ if (!useServerObjectCards) return undefined
+ for (const t of form.targets) {
+ const s = objectServerCardRows.find((x) => serverRowLabel(x) === t.trim())
+ if (s) return s
+ }
+ return undefined
+ }, [useServerObjectCards, objectServerCardRows, form.targets])
+
+ const objectSummaryGre = useMemo((): AlertsGreTunnelRow | undefined => {
+ if (!useGreTunnelObjectCards) return undefined
+ for (const t of form.targets) {
+ const r = objectGreCardRows.find((x) => x.targetLabel === t.trim())
+ if (r) return r
+ }
+ return undefined
+ }, [useGreTunnelObjectCards, objectGreCardRows, form.targets])
+
+ const toggleTarget = useCallback((v: string) => {
+ const t = v.trim()
+ if (!t) return
+ nameTouchedRef.current = false
+ setForm((f) => {
+ const set = new Set(f.targets)
+ if (set.has(t)) set.delete(t)
+ else set.add(t)
+ const next = [...set]
+ // Не сворачиваем после каждого клика по карточке — иначе мульти-выбор серверов невозможен без повторного «Изменить».
+ queueMicrotask(() => {
+ if (next.length === 0) setObjectExpanded(true)
+ })
+ return { ...f, targets: next }
+ })
+ }, [])
+
+ useEffect(() => {
+ if (!open) return
+ if (nameTouchedRef.current) return
+ const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
+ queueMicrotask(() => {
+ if (trivialEmpty) {
+ if (!editRuleId) {
+ setForm((f) => (f.name === "" ? f : { ...f, name: "" }))
+ }
+ return
+ }
+ const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
+ setForm((f) => (f.name === nextName ? f : { ...f, name: nextName }))
+ })
+ }, [open, editRuleId, form.type, form.targets, form.conditions, form.threshold])
+
+ const expandedConditionLines = useMemo(
+ () =>
+ form.conditions.map((c) =>
+ unit && form.threshold.trim() && c.includes("порога")
+ ? c.replace("порога", `${form.threshold.trim()} ${unit}`)
+ : c,
+ ),
+ [form.conditions, form.threshold, unit],
+ )
+
+ const conditionDisplay = expandedConditionLines.join("\n")
+
+ const canSave =
+ form.name.trim() && targetOk && form.conditions.length > 0 && (!unit || form.threshold)
+
+ const handleTestRuleTelegram = async () => {
+ if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
+ setTestTelegramBusy(true)
+ try {
+ const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
+ await onTestRuleTelegram({
+ name: form.name.trim(),
+ targets: uniqStrings(form.targets),
+ conditionLine,
+ severity: form.severity,
+ cooldown: form.cooldown,
+ ruleChatId: form.chatId.trim(),
+ })
+ } finally {
+ setTestTelegramBusy(false)
+ }
+ }
+
+ const handleSave = async () => {
+ if (!canSave||saveDisabled||saving) return
+ const uq = uniqStrings(form.targets)
+ const targetSummary = uq.join(", ").slice(0, 240) || "—"
+ const condResolved = uniqStrings(expandedConditionLines.map((c) => c.trim()).filter(Boolean))
+ const saved=await onSave({
+ name: form.name.trim(),
+ type: form.type,
+ target: targetSummary,
+ targets: uq,
+ groupId: form.groupId && groups.some((g) => g.id === form.groupId) ? form.groupId : null,
+ condition: summarizeConditionsUi(condResolved),
+ conditions: condResolved,
+ severity: form.severity,
+ cooldown: form.cooldown,
+ confirmStabilitySec: form.confirmStabilitySec,
+ recoveryMode:form.recoveryMode??"always", recoveryStabilitySec:form.recoveryStabilitySec??null,
+ chatId: form.chatId.trim(),
+ }, editRuleId)
+ if(saved)onClose()
+ }
+
+ const applySuggestedName = useCallback(() => {
+ nameTouchedRef.current = false
+ const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
+ if (!editRuleId && trivialEmpty) {
+ setForm((f) => ({ ...f, name: "" }))
+ return
+ }
+ const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
+ setForm((f) => ({ ...f, name: nextName }))
+ }, [editRuleId, form.type, form.targets, form.conditions, form.threshold])
+
+ const toggleCondition = useCallback((v: string) => {
+ const t = v.trim()
+ if (!t) return
+ nameTouchedRef.current = false
+ setForm((f) => {
+ const set = new Set(f.conditions)
+ if (set.has(t)) set.delete(t)
+ else set.add(t)
+ const next = [...set]
+ queueMicrotask(() => setObjectExpanded(false))
+ return { ...f, conditions: next }
+ })
+ }, [])
+
+ return (
+ { if (!o) onClose() }}>
+ {/* p-0 + gap-0: we control all spacing internally */}
+
+
+ {/* ── fixed header ── */}
+
+
+
+ {editRuleId ? "Изменение правила" : "Новое правило оповещения"}
+
+
+
+ {/* ── scrollable body ── */}
+
+
+ {isLive&&form.type==="bgp-prefix"?Отслеживание появления и отзыва префиксов пока недоступно. Сохранённое правило не проверяется; можно изменить его тип или отключить в списке.
:null}
+
+ {/* name */}
+
+
+ Название правила
+ {
+
+ applySuggestedName()
+ }}
+ >
+ Сгенерировать
+
+
+
{
+ nameTouchedRef.current = true
+ setForm((f) => ({ ...f, name: e.target.value }))
+ }}
+ />
+ {!editRuleId && (
+
+ Название пересобирается при смене типа, объекта, условия или порога. После ручного ввода в поле автоподстановка отключается.
+
+ )}
+
+
+ {/* type — карточки как «Действие» на /filters */}
+
+
Тип события
+
+ {ALERT_TYPES_ORDER.map((t) => {
+ const m = TYPE_META[t]
+ const { Icon, iconClass, bg } = m
+ const active = form.type === t
+ return (
+
{
+ nameTouchedRef.current = false
+
+ setForm((f) => ({
+ ...f,
+ type: t,
+ targets: [],
+ conditions: [],
+ threshold: "",
+ }))
+ queueMicrotask(() => setObjectExpanded(true))
+ }}
+ className={cn(
+ "flex flex-col items-start gap-2 rounded-lg border px-3 py-3 text-left transition-all w-full",
+ active
+ ? "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
+ )}
+ >
+
+
+
+
+
+ {m.label}
+
+ {active &&
}
+
+
+ {isLive&&t==="bgp-prefix"?"Отслеживание префиксов пока недоступно":TYPE_EVENT_CARD_DESC[t]}
+
+
+ )
+ })}
+
+
+
+
+
+ {/* Объект и условие — вертикально; после выбора объекта список сворачивается, условия появляются */}
+
+ {allowManualTarget ? (
+
+
Объект
+ {(
+ <>
+
{
+ nameTouchedRef.current = false
+ const v = e.target.value.trim()
+ setForm(f => ({ ...f, targets: v ? [v] : [] }))
+ queueMicrotask(() => setObjectExpanded(!v))
+ }}
+ className="text-xs font-mono h-8"
+ placeholder={
+ isLive
+ ? "Точное имя объекта (сервер, сосед, префикс…)"
+ : "Или выберите из списка в демо-режиме"
+ }
+ />
+ {isLive && (
+
+ Нет готового списка для этого типа — укажите объект вручную (как в данных мониторинга).
+
+ )}
+ >
+ )}
+
+ ) : (
+
+
Объект
+
+ Можно выбрать несколько карточек — сработает при событии на любом из них (OR).
+
+ {(
+
+
+
+
+ {useGreTunnelObjectCards ? (
+
+ ) : useServerObjectCards ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {
+
+ setObjectExpanded(true)
+ }}
+ />
+
+
+
+
+ )}
+
+ )}
+
+
+
Условие
+ {!hasObject ? (
+
+ {allowManualTarget
+ ? "Укажите объект выше — затем появится выбор условия."
+ : "Сначала выберите объект (можно несколько). Варианты условия появятся ниже; после выбора условия список объектов свернётся — снова развернуть кнопкой «Изменить»."}
+
+ ) : (
+ <>
+
+ Можно выбрать несколько карточек — уведомление при срабатывании любого из условий (OR), например offline и восстановление.
+
+
+ >
+ )}
+
+
+
+ {/* threshold — only for RTT / loss / traffic */}
+ {unit && (
+
+
Пороговое значение
+
+ {
+ nameTouchedRef.current = false
+ setForm((f) => ({ ...f, threshold: e.target.value }))
+ }}
+ className="rounded-r-none"
+ placeholder="0"
+ />
+
+ {unit}
+
+
+
+ )}
+
+ {/* severity — vertical radio cards */}
+
+
Серьёзность
+
+ {(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
+ const active = form.severity === s
+ return (
+
{
+
+ setForm((f) => ({ ...f, severity: s }))
+ }}
+ className={cn(
+ "flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
+ active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
+ )}>
+
+
+
{SEVERITY_META[s].label}
+
+ {s === "critical" ? "Немедленное уведомление, звуковой сигнал"
+ : s === "warning" ? "Важное событие, тихое уведомление"
+ : "Информационное, без уведомления"}
+
+
+ {active && }
+
+ )
+ })}
+
+
+
+ {/* cooldown */}
+
+
Повторять не чаще чем
+
+ {COOLDOWNS.map(c => (
+ {
+
+ setForm((f) => ({ ...f, cooldown: c }))
+ }}
+ className={cn(
+ "px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
+ form.cooldown === c
+ ? "border-primary bg-primary/10 text-primary font-semibold"
+ : "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+ )}>
+ {c}
+
+ ))}
+
+
+
+ {/* подтверждение стабильности (антидребезг) */}
+
+
Задержка перед отправкой
+
+ {CONFIRM_STABILITY_OPTIONS.map((o) => {
+ const active =
+ (o.value == null && (form.confirmStabilitySec == null || form.confirmStabilitySec <= 0)) ||
+ (o.value != null && form.confirmStabilitySec === o.value)
+ return (
+ {
+
+ setForm((f) => ({ ...f, confirmStabilitySec: o.value }))
+ }}
+ className={cn(
+ "px-3 py-1.5 rounded-lg border text-sm transition-colors",
+ active
+ ? "border-primary bg-primary/10 text-primary font-semibold"
+ : "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
+ )}
+ >
+ {o.label}
+
+ )
+ })}
+
+
+ Если за выбранное время условие перестанет выполняться (сигнал «отменится»), уведомление не отправится.
+ Не зависит от cooldown: сначала ждём стабильность, затем проверяем cooldown после фактической отправки.
+
+
+
+
+ Группа правил (ANY / ALL)
+ {
+ const v = e.target.value
+ setForm((f) => ({ ...f, groupId: v ? v : null }))
+ }}
+ >
+ Без группы (отдельное уведомление)
+ {groups.map((g) => (
+
+ {g.name} ({g.combineMode === "any" ? "ANY" : "ALL"})
+ {!g.enabled ? " · выкл." : ""}
+
+ ))}
+
+
+ ANY — уведомление при срабатывании любого правила в группе. ALL — когда сработали все правила группы одновременно.
+
+
+
+
+ Chat ID — переопределить
+ setForm(f => ({ ...f, chatId: e.target.value }))}
+ className="font-mono text-sm"
+ placeholder="-100xxxxxxxxxx"
+ />
+ Пусто → используется глобальный Chat ID из настроек Telegram
+
+
+ {/* Telegram message preview */}
+ {form.name && form.targets.length > 0 && form.conditions.length > 0 && (
+
+
+
+ Предпросмотр сообщения
+
+
+
+
+ {form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
+ {" "}{form.name}
+
+
Объект: {form.targets.join(", ")}
+
+ Событие: {conditionDisplay || summarizeConditionsUi(form.conditions)}
+
+
Cooldown: {form.cooldown}
+
+ Chat: {form.chatId || "(глобальный)"}
+
+
+
+ )}
+
+
+ {/* ── fixed footer ── */}
+
+
+ Отмена
+
+ {isLive && onTestRuleTelegram ? (
+ void handleTestRuleTelegram()}
+ >
+ {testTelegramBusy ? (
+
+ ) : (
+
+ )}
+ Тест в Telegram
+
+ ) : null}
+ {saveError?Обновить снимок — сохранить черновик :null}
+ {testError?{testError}
:null}
+ {testOutcome?{testOutcome}
:null}
+ {testReview}
+ {review}
+ {saveError?{saveError}
:null}
+ void handleSave()} disabled={!canSave||saveDisabled||saving}>
+ {editRuleId ? "Сохранить изменения" : "Сохранить правило"}
+
+
+
+
+ )
+}
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
+type SeverityFilter = AlertSeverity | "all"
+
+export default function AlertsPage(){const {mode,backendUrl}=useDataSource();return }
+
+function AlertsContent() {
+ const { mode, backendUrl, backendStatus } = useDataSource()
+ const isLive = mode === "live"
+
+ const [rules, setRules] = useState(isLive?[]:INIT_RULES)
+ const [groups, setGroups] = useState(isLive?[]:INIT_GROUPS)
+ const [history, setHistory] = useState(isLive?[]:INIT_HISTORY)
+ const [tg, setTg] = useState(isLive?{token:"",chatId:"",messageThreadId:"",connected:false}:INIT_TG)
+ const [tokenConfigured, setTokenConfigured] = useState(false)
+ const [baseMeta, setMeta] = useState(null)
+ const catalogs=useAlertsTargets(backendUrl,isLive)
+ const meta=useMemo(()=>{
+ if(!baseMeta)return null
+ const serverMap=new Map(catalogs.servers.data?.map(server=>[String(server.id),server]))
+ const greTunnelDetail:AlertsGreTunnelRow[]|undefined=catalogs.gre.data?.tunnels.map(tunnel=>{
+ const server=serverMap.get(tunnel.serverId)
+ const routerName=server?.name.trim()||tunnel.serverId
+ const tunnelName=tunnel.name.trim()||tunnel.id?.trim()||"GRE"
+ return {targetLabel:tunnelName+" / "+routerName,tunnelName,routerName,country:server?.country||"—",site:server?.site||"—",remoteAddress:tunnel.remoteAddress||"—",status:tunnel.status?.trim()||"Неизвестно",comment:tunnel.comment}
+ })
+ return {...baseMeta,bgpPeers:catalogs.bgp.data?.map(row=>row.serverName+": "+row.name),greTunnelDetail,greTunnelTargets:greTunnelDetail?.map(row=>row.targetLabel)}
+ },[baseMeta,catalogs.bgp.data,catalogs.servers.data,catalogs.gre.data])
+
+ const [configRevision,setConfigRevision]=useState(null)
+ const [known,setKnown]=useState(!isLive)
+ const [receivedAt,setReceivedAt]=useState(null)
+ const pageActive=useRef(true),reader=useRef(null),revision=useRef(0)
+ useEffect(()=>{pageActive.current=true;return()=>{pageActive.current=false;reader.current?.abort()}},[])
+
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState(null)
+ const [sheetSnap, setSheetSnap] = useState(null)
+ const [sheetEditId, setSheetEditId] = useState(null)
+ const [sheetKey, setSheetKey] = useState(0)
+ const [sevFilter, setSevFilter] = useState("all")
+ const [onlyActive, setOnlyActive] = useState(false)
+ const [search, setSearch] = useState("")
+
+ const [loadBusy, setLoadBusy] = useState(false)
+ const [loadError, setLoadError] = useState(null)
+ const operationLock=useRef(false)
+ const [operationBusy,setOperationBusy]=useState(false)
+ const beginOperation=()=>{if(!pageActive.current||operationLock.current)return false;operationLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setOperationBusy(true);return true}
+ const endOperation=()=>{operationLock.current=false;setOperationBusy(false)}
+ const config=useAlertsConfigWrite(backendUrl,isLive,known&&backendStatus===true,configRevision,beginOperation,endOperation)
+ const preview=useAlertsTelegramPreview(backendUrl,isLive,known&&backendStatus===true&&tokenConfigured,beginOperation,endOperation)
+ const rulesSaveBusy=config.busy
+ const writesDisabled=!config.enabled||operationBusy||config.unknown
+ const [pendingDelete,setPendingDelete]=useState<{kind:"rule"|"group";id:string}|null>(null)
+ /** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */
+ const [extrasExpanded, setExtrasExpanded] = useState(false)
+ const [hintsExpanded, setHintsExpanded] = useState(false)
+
+ const typeTargets = useMemo(() => {
+ if (!isLive) return TYPE_TARGETS
+ if (!meta) return emptyTypeTargets()
+ return buildLiveTypeTargets(meta)
+ }, [isLive, meta])
+
+ const serversCatalog = useMemo((): AlertsServerRow[] => {
+ if (isLive && meta?.serversDetail?.length) return meta.serversDetail
+ if (!isLive) {
+ return TYPE_TARGETS.server.map((name, i) => ({
+ name,
+ host: `192.0.2.${(i % 220) + 10}`,
+ site: ["MSK", "SPB", "FRA", "AMS", "SGP"][i % 5] ?? "",
+ country: ["RU", "RU", "DE", "NL", "SG"][i % 5] ?? "",
+ type: (["jump-host", "exit-node", "home-router"] as const)[i % 3] as string,
+ }))
+ }
+ return []
+ }, [isLive, meta])
+
+ const greTunnelCatalog = useMemo((): AlertsGreTunnelRow[] => {
+ if (isLive && meta?.greTunnelDetail?.length) return meta.greTunnelDetail
+ if (!isLive) return demoGreTunnelCatalog()
+ return meta?.greTunnelDetail ?? []
+ }, [isLive, meta])
+
+ const load = useCallback(async () => {
+ if(!pageActive.current||operationLock.current)return
+ if (!isLive) {
+ setRules(INIT_RULES)
+ setGroups(INIT_GROUPS)
+ setHistory(INIT_HISTORY)
+ setTg(INIT_TG)
+ setTokenConfigured(false)
+ setMeta(null)
+ setLoadError(null)
+ return
+ }
+ reader.current?.abort()
+ const controller=new AbortController();reader.current=controller
+ const requestRevision=++revision.current
+ setLoadBusy(true)
+ setLoadError(null)
+ try {
+ const data = await getAlerts(backendUrl,{signal:controller.signal})
+ if(!pageActive.current||requestRevision!==revision.current)return
+ setConfigRevision(data.revision);setKnown(true);setReceivedAt(new Date().toISOString())
+ setTokenConfigured(data.telegram.tokenConfigured)
+ setTg({
+ token: "",
+ chatId: data.telegram.chatId ?? "",
+ messageThreadId:
+ data.telegram.messageThreadId != null ? String(data.telegram.messageThreadId) : "",
+ connected: data.telegram.connected,
+ })
+ setRules(mapApiRules(data.rules))
+ setGroups(mapApiGroups(data.groups ?? []))
+ setHistory(mapApiHistory(data.history))
+ setMeta(data.meta)
+ } catch (e) {
+ if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
+ } finally {
+ if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false)
+ }
+ }, [isLive, backendUrl])
+
+ useEffect(() => {
+ let current=true
+ queueMicrotask(() => {
+ if(current)void load()
+ })
+ return()=>{current=false}
+ }, [load])
+
+ const flushAlertsConfig=useCallback(async(nextRules:AlertRule[],nextGroups:AlertGroup[])=>{
+ const receipt=await config.save({rules:nextRules.map(toApiRuleBody),groups:nextGroups.map(toApiGroupBody)})
+ if(!receipt||!pageActive.current)return false
+ setConfigRevision(receipt.revision);setRules(mapApiRules(receipt.rules));setGroups(mapApiGroups(receipt.groups));return true
+ },[config])
+
+ const testRuleTelegramFromForm=async(p:RuleTelegramTestPayload)=>preview.test({
+ chatId:p.ruleChatId.trim()||tg.chatId.trim()||undefined,
+ ...(tg.messageThreadId?{messageThreadId:Number(tg.messageThreadId)}:{}),
+ rulePreview:{name:p.name,targets:p.targets,conditionLine:p.conditionLine,severity:p.severity,cooldown:p.cooldown},
+ })
+
+ const serverNamesForPresets = useMemo(() => {
+ if (isLive) return meta?.servers??[]
+ return TYPE_TARGETS.server
+ }, [isLive, meta])
+
+ const applyPresetAppend = useCallback(
+ (presetId: string) => {
+ const def = ALERT_PRESETS.find((p) => p.id === presetId)
+ if (!def) return
+ const built = def.build(presetId==="rtt-8888-pack"?(isLive?(meta?.rttLossTargets??[]):TYPE_TARGETS.rtt).filter(target=>target.endsWith("→ 8.8.8.8")):serverNamesForPresets)
+ if(!built.length){setLoadError("Для пресета нет подтверждённых объектов. Список правил сохранён.");return}
+ const newRows: AlertRule[] = built.map((b) => ({
+ ...b,
+ id:
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ enabled: true,
+ lastFired: null,
+ }))
+ void flushAlertsConfig([...rules, ...newRows], groups)
+ },
+ [isLive,meta,serverNamesForPresets, rules, groups, flushAlertsConfig],
+ )
+
+ const applyPresetReplace = useCallback(
+ (presetId: string) => {
+ const def = ALERT_PRESETS.find((p) => p.id === presetId)
+ if (!def) return
+ const built = def.build(presetId==="rtt-8888-pack"?(isLive?(meta?.rttLossTargets??[]):TYPE_TARGETS.rtt).filter(target=>target.endsWith("→ 8.8.8.8")):serverNamesForPresets)
+ if(!built.length){setLoadError("Для пресета нет подтверждённых объектов. Список правил сохранён.");return}
+ const newRows: AlertRule[] = built.map((b) => ({
+ ...b,
+ id:
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ enabled: true,
+ lastFired: null,
+ }))
+ void flushAlertsConfig(newRows, groups)
+ },
+ [isLive,meta,serverNamesForPresets, groups, flushAlertsConfig],
+ )
+
+ const addGroup = useCallback(() => {
+ const g: AlertGroup = {
+ id:
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : `grp-${Date.now()}`,
+ name: "Новая группа",
+ combineMode: "any",
+ enabled: true,
+ cooldownOverride: null,
+ }
+ void flushAlertsConfig(rules, [...groups, g])
+ }, [rules, groups, flushAlertsConfig])
+
+ const patchGroup = useCallback(
+ (id: string, patch: Partial) => {
+ return flushAlertsConfig(
+ rules,
+ groups.map((x) => (x.id === id ? { ...x, ...patch } : x)),
+ )
+ },
+ [rules, groups, flushAlertsConfig],
+ )
+
+ const removeGroup = useCallback(
+ (id: string) => {
+ const nextRules = rules.map((r) => (r.groupId === id ? { ...r, groupId: null } : r))
+ void flushAlertsConfig(nextRules, groups.filter((g) => g.id !== id))
+ },
+ [rules, groups, flushAlertsConfig],
+ )
+
+ const total = rules.length
+ const active = rules.filter(r => r.enabled).length
+ const critical = rules.filter(r => r.severity === "critical").length
+ const warning = rules.filter(r => r.severity === "warning").length
+ const info = rules.filter(r => r.severity === "info").length
+
+ const filteredRules = useMemo(() => rules.filter(r => {
+ if (sevFilter !== "all" && r.severity !== sevFilter) return false
+ if (onlyActive && !r.enabled) return false
+ const blob =
+ `${r.name} ${r.target} ${r.targets.join(" ")} ${r.condition} ${r.conditions.join(" ")}`.toLowerCase()
+ if (search && !blob.includes(search.toLowerCase())) return false
+ return true
+ }), [rules, sevFilter, onlyActive, search])
+
+ const handleToggle = (id: string, enabled: boolean) => {
+ const next = rules.map(r => r.id === id ? { ...r, enabled } : r)
+ void flushAlertsConfig(next, groups)
+ }
+
+ const handleDelete = (id: string) => {
+ const next = rules.filter(r => r.id !== id)
+ void flushAlertsConfig(next, groups)
+ }
+
+ const closeRuleSheet = useCallback(() => {
+ if(operationLock.current)return
+ setSheetOpen(false)
+ setSheetEditId(null)
+ setSheetSnap(null)
+ }, [])
+
+ const openRuleSheetAdd = useCallback(() => {
+ if(!config.enabled||operationLock.current||config.unknown)return
+ preview.clear();setSheetEditId(null)
+ setSheetSnap(null)
+ setSheetKey((k) => k + 1)
+ setSheetOpen(true)
+ }, [config.enabled,config.unknown,preview])
+
+ const openRuleSheetEdit = useCallback((id: string) => {
+ if(!config.enabled||operationLock.current||config.unknown)return
+ const r = rules.find((x) => x.id === id)
+ if (!r) return
+ preview.clear();setSheetSnap(ruleToFormSnapshot(r))
+ setSheetEditId(id)
+ setSheetKey((k) => k + 1)
+ setSheetOpen(true)
+ }, [rules,config.enabled,config.unknown,preview])
+
+ const handleRuleSheetSave = useCallback(
+ async (payload: Omit, editId: string | null) => {
+ if (editId) {
+ const cur = rules.find((x) => x.id === editId)
+ if (!cur){setLoadError("Правило больше не существует. Обновите список и проверьте черновик.");return false}
+ const next = rules.map((x) =>
+ x.id === editId ? { ...x, ...payload, id: editId, enabled: cur.enabled, lastFired: cur.lastFired } : x,
+ )
+ return flushAlertsConfig(next, groups)
+ }
+ const row: AlertRule = {
+ ...payload,
+ id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `r${Date.now()}`,
+ enabled: true,
+ lastFired: null,
+ }
+ const next = [...rules, row]
+ return flushAlertsConfig(next, groups)
+ },
+ [rules, groups, flushAlertsConfig],
+ )
+
+ // Summary chip click handler
+ const handleChip = (sev: SeverityFilter) =>
+ setSevFilter(f => f === sev ? "all" : sev)
+
+ const chipActive = (sev: SeverityFilter) => sevFilter === sev
+
+ return (
+
+
+ { void load(); catalogs.refresh() }}>
+ {loadBusy ? : }
+ Обновить
+
+
+ Добавить правило
+
+ >
+ }
+ />
+
+
+
+
+ {isLive && backendStatus === false && (
+
+ Бэкенд недоступен по текущему URL. Проверьте адрес в настройках источника данных или переключитесь в режим демо.
+
+ )}
+
+ {!isLive && (
+
+ Режим демо: показаны примерные данные без сохранения. Включите «Живые данные» в источнике данных для работы с БД.
+
+ )}
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+ {isLive?
{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}
:null}
+ {!can("mm:alerts:write")?
Нет права изменять правила и группы.
:null}
+ {config.error?
{config.error}
:null}
+ {config.outcome?
{config.outcome}
:null}
+ {config.busy?
Ожидаем подтверждение правил и групп…
:null}
+ {config.unknown&&!config.busy?
Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.
Я проверил правила — разрешить новый запрос :null}
+ {known?<>
+ {isLive &&
}
+ {/* ── summary chip bar ── */}
+
+
+ {/* stat chips */}
+ {([
+ { key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" },
+ { key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
+ { key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
+ { key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip },
+ ] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
+
handleChip(key)}
+ className={cn(
+ "text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
+ chipActive(key)
+ ? cls
+ : "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
+ )}>
+ {label}
+
+ ))}
+
+
+
+ {/* active count chip */}
+
setOnlyActive(v => !v)}
+ className={cn(
+ "text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
+ onlyActive
+ ? "border-emerald-400 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
+ : "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
+ )}>
+ Активных: {active}
+
+
+ {/* telegram status */}
+
+
+ {tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
+
+
+
+ >:null}
+
+
+ {known?<>
+
+ Правила оповещения
+ {rulesSaveBusy && }
+
+ }
+ contentClassName="px-0 pb-0 pt-2"
+ >
+ setSearch(e.target.value)} autoComplete="off" name="alert-rules-search" />
+ {filteredRules.length === 0 ? (
+
+ ) : (
+
+ {filteredRules.map(r => (
+
setPendingDelete({kind:"rule",id})}
+ onEdit={openRuleSheetEdit}
+ interactionsDisabled={writesDisabled}
+ isLive={isLive}
+ />
+ ))}
+
+ )}
+
+ {/* footer */}
+
+
+ {filteredRules.length} из {total} правил
+ {onlyActive && " · только активные"}
+ {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
+
+
+ Добавить правило
+
+
+
+
+ >:null}
+ {isLive?
:null}
+{isLive?:null}
+
+ {known?
+
+
{setTg(value);setTokenConfigured(configured)}}/>
+
+
+ :null}
+
+ {known?<>
+ {/* ── presets + groups (свёрнуто по умолчанию) ── */}
+
+
+
+
+ Пресеты и группы ANY / ALL
+
+ Массовое добавление правил и объединение нескольких правил в одно уведомление
+
+
+
+ {groups.length ? `${groups.length} гр.` : "групп нет"}
+
+
+
+
+
+
+
+ Быстро добавить набор правил из каталога серверов (демо или live meta).
+
+ {ALERT_PRESETS.map((p) => (
+
+
{p.title}
+
{p.description}
+
+ applyPresetAppend(p.id)}
+ >
+ Добавить к списку
+
+ setPendingPresetReplaceId(p.id)}
+ >
+ Заменить все
+
+
+
+ ))}
+
+
+
+ Группа
+
+ }
+ contentClassName="px-4 pb-4 flex flex-col gap-3"
+ >
+ {groups.length === 0 ? (
+
+ Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
+
+ ) : (
+ groups.map(g=>patchGroup(g.id,patch)} onDelete={()=>setPendingDelete({kind:"group",id:g.id})}/>)
+ )}
+
+
+
+
+
+
+
+
+
+ Подсказки: формат целей и «восстановился»
+
+
+
+ Движок читает последние сэмплы из PostgreSQL (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
+ срабатывает только если цепочка состояний совпадает с текстом правила.
+
+
+
+ RTT / потери — в объектах укажите ключ пробы
+ как в мониторинге: имя пробы → target.
+
+
+ Восстановился — нужна история: после падения
+ в последних сэмплах виден возврат в online (REST и метрики ресурса объединяются). Один текущий
+ online без прошлого offline часто не даёт hit.
+
+
+ Перешёл в offline — только на ребре (предыдущий
+ сэмпл был online). Пока сервер остаётся offline, повторные уведомления не шлются.
+
+
+ Стабильность / cooldown — задержка перед
+ Telegram и пауза между повторными отправками. В разделе «Сбор данных» в деталях прогона «Оповещения»
+ есть таблица по каждому правилу (hit, стабильность, cooldown, Telegram, причина блока).
+
+
+
+
+
+ >:null}
+
+
+
+ void load()}
+ saving={rulesSaveBusy} saveDisabled={writesDisabled} saveError={config.error??loadError}
+ review={config.unknown&&!config.busy?Результат записи неизвестен. Проверьте правила в БД перед повтором; черновик сохранён в этой форме.
Я проверил правила — разрешить новый запрос :null}
+ typeTargets={typeTargets}
+ isLive={isLive}
+ serversCatalog={serversCatalog}
+ greTunnelCatalog={greTunnelCatalog}
+ groups={groups}
+ initialForm={sheetSnap}
+ editRuleId={sheetEditId}
+ onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
+ testTelegramDisabled={!preview.enabled||operationBusy||preview.unknown}
+ testOutcome={preview.outcome} testError={preview.error}
+ testReview={preview.unknown&&!preview.busy?Результат Telegram неизвестен. Проверьте чат перед повтором: сообщение могло отправиться. Черновик правила не записан.
Я проверил Telegram — разрешить новый запрос :null}
+ />
+
+ {if(!open&&!operationLock.current)setPendingDelete(null)}}>
+ {pendingDelete?.kind==="group"?"Удалить группу?":"Удалить правило?"} {pendingDelete?.kind==="group"?"Правила сохранятся, но больше не будут входить в эту группу.":"Правило перестанет участвовать в оповещениях после подтверждения сервера."} Отмена {if(!pendingDelete)return;if(pendingDelete.kind==="rule")handleDelete(pendingDelete.id);else removeGroup(pendingDelete.id);setPendingDelete(null)}}>Удалить
+
+ {
+ if (!open) setPendingPresetReplaceId(null)
+ }}
+ >
+
+
+ Заменить все правила?
+
+ Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
+
+
+
+ Отмена
+ {
+ if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
+ setPendingPresetReplaceId(null)
+ }}
+ >
+ Заменить
+
+
+
+
+
+ )
+}
diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx
new file mode 100644
index 00000000..c5338203
--- /dev/null
+++ b/app/(main)/asns/page.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { PageHeader } from "@/components/page-header"
+import { DataPageCard } from "@/components/data-page-card"
+import { DataPageToolbar } from "@/components/data-page-toolbar"
+import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
+import { FileImportDialog } from "@/components/file-import-dialog"
+import { asns as mockAsns } from "@/lib/data"
+import { Button } from "@/components/ui/button"
+import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
+import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
+import { toast } from "sonner"
+
+export default function AsnsPage() {
+ const { mode, snapshot, loading, known } = useEvoCatalogView()
+ const [importOpen, setImportOpen] = useState(false)
+ const [search, setSearch] = useState("")
+
+ const useEvoCatalog = mode === "live"
+
+ const rows = useMemo(() => {
+ if (mode === "mock") return mockAsns
+ if (loading && !snapshot) return []
+ return snapshot?.asns ?? []
+ }, [mode, loading, snapshot])
+
+ const filtered = useMemo(() => {
+ if (!search) return rows
+ const q = search.toLowerCase()
+ return rows.filter(
+ (r) =>
+ r.asn.toLowerCase().includes(q) ||
+ r.org.toLowerCase().includes(q) ||
+ (r.prefixes === null ? "" : String(r.prefixes)).includes(q),
+ )
+ }, [rows, search])
+
+ return (
+
+
+ setImportOpen(true)}>
+ Импорт
+
+ Экспорт
+ Добавить ASN
+ >
+ }
+ />
+
+
+
+
ASN
+
+ Автономные системы — источники маршрутов для BGP-фильтров
+
+
+
+
+
+ {known || loading ? : }
+
+
+
+ {
+ toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
+ }}
+ />
+
+ )
+}
diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx
new file mode 100644
index 00000000..1b0d1d96
--- /dev/null
+++ b/app/(main)/backups/page.tsx
@@ -0,0 +1,623 @@
+"use client"
+
+import { Suspense, useCallback, useEffect, useRef, useState } from "react"
+import { Frame, FramePanel } from "@/components/reui/frame"
+import { PageHeader } from "@/components/page-header"
+import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
+import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
+import { backupStorageSettingsDtoSchema, type BackupScheduleSettingsDto, type BackupStorageSettingsDto } from "@mmapp/contracts/backups"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/reui/alert"
+import { Button } from "@/components/ui/button"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ CloudIcon,
+ ClockIcon,
+ HardDriveIcon,
+ PlusIcon,
+ RefreshCwIcon,
+} from "lucide-react"
+import { useDataSource } from "@/lib/data-source"
+import { serverListSchema } from "@mmapp/contracts/servers"
+import { can } from "@/lib/auth"
+import { userApiError } from "@/shared/api/users"
+import { toFrontendServer } from "@/entities/server/model/mappers"
+import {
+ deleteBackup,
+ downloadBackup,
+ getBackupScheduleSettings,
+ getBackupStorageSettings,
+ listBackups,
+ putBackupScheduleSettings,
+ putBackupStorageSettings,
+ restoreBackup,
+ syncBackupsFromStorage,
+ testBackupStorage,
+ type BackupItem,
+} from "@/shared/api/backups"
+import { ApiClientError, requestJson } from "@/shared/api/http-client"
+import { toast } from "sonner"
+import { useBackupRestoreGuard } from "@/lib/use-backup-restore-guard"
+import { restoreWasNotApplied } from "@/lib/backup-restore-checkpoint"
+import { Checkbox } from "@/components/ui/checkbox"
+import { useBackupJob } from "@/lib/use-backup-job"
+import { backupJobOutcome, backupJobResultError } from "@/lib/backup-job-poll"
+import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings"
+import { BackupsHistory } from "@/components/backups/backups-history"
+import { backupFileMissing } from "@/lib/backup-file-state"
+import {
+ BackupsSettings,
+ WEEK_DAYS,
+ defaultSchedule,
+ defaultStorageForm,
+ type BackupScheduleForm,
+ type BackupStorageForm,
+} from "@/components/backups/backups-settings"
+import { BackupCreateSheet } from "@/components/backups/backup-create-sheet"
+import { BackupDeleteDialog, BackupRestoreDialog } from "@/components/backups/backup-confirm-dialogs"
+
+type PageTab = "history" | "settings"
+
+type Resource = "backups" | "servers" | "schedule" | "storage"
+type ResourceState = { known: boolean; error: string | null; receivedAt: string | null }
+const unknownResource = (): ResourceState => ({ known: false, error: null, receivedAt: null })
+
+export default function BackupsPage() { return Загрузка контекста резервных копий…
}> }
+function BackupsSource() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ if (!prefsHydrated) return Загрузка настроек источника…
+ return
+}
+function BackupsContent() {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+ const canRead = can("mm:backups:read")
+ const canWrite = isLive && can("mm:backups:write")
+ const canReadServers = can("mm:servers:read")
+ const [resources, setResources] = useState>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
+ const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
+ const refreshTrigger = useRef(null)
+ const captureDialogFocus = () => {
+ const element = document.activeElement
+ dialogTrigger.current = element instanceof HTMLElement
+ ? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") }
+ : null
+ }
+ const returnDialogFocus = () => {
+ const trigger = dialogTrigger.current
+ if (trigger?.element.isConnected) return trigger.element
+ // Cell renderers can remount when operation state changes; resolve the same row/action.
+ if (trigger?.rowId && trigger.label) {
+ const replacement = document.querySelector(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
+ if (replacement) return replacement
+ }
+ return refreshTrigger.current
+ }
+ const reader = useRef(null)
+ const revision = useRef(0)
+ const alive = useRef(true)
+ const scheduleDirty = useRef(false)
+ const storageDirty = useRef(false)
+ const operationLock = useRef(false)
+ const operationController = useRef(null)
+ const downloadControllers = useRef(new Map())
+ const settingsLock = useRef(false)
+ const settingsController = useRef(null)
+ const known = (resource: Resource) => !isLive || resources[resource].known
+ const current = (resource: Resource) => known(resource) && !resources[resource].error
+ const { tab, setTab, kindFilter, setKindFilter, search, setSearch, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, reset: resetContext } = useBackupsUrlState()
+ const [backupList, setBackupList] = useState(isLive ? [] : mockBackups)
+ const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers)
+ const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage")
+ const [loading, setLoading] = useState(false)
+ const restoreGuard = useBackupRestoreGuard(backendUrl, isLive)
+ const [restoreReviewed, setRestoreReviewed] = useState(false)
+ const [opBusy, setOpBusy] = useState(false)
+ const [opError, setOpError] = useState(null)
+ const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
+ const [testBusy, setTestBusy] = useState(false)
+ const [syncBusy, setSyncBusy] = useState(false)
+ const [schedule, setSchedule] = useState(defaultSchedule)
+ const [storage, setStorage] = useState(defaultStorageForm)
+ const [storageIsDirty, setStorageIsDirty] = useState(false)
+ const [savedSchedule, setSavedSchedule] = useState(null)
+ const [savedStorage, setSavedStorage] = useState(null)
+ const [scheduleLastError, setScheduleLastError] = useState(null)
+ const [allEnabled, setAllEnabled] = useState(true)
+ const [selectedServers, setSelectedServers] = useState>(new Set())
+ const [manualOpen, setManualOpen] = useState(false)
+ const [manualStep, setManualStep] = useState(1)
+ const [manualServers, setManualServers] = useState>(new Set())
+ const [manualNotes, setManualNotes] = useState("")
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const [restoreTarget, setRestoreTarget] = useState(null)
+
+ const setSched = (k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
+ const setStore = (k: K, v: BackupStorageForm[K]) => { if (k !== "showPassword") { storageDirty.current = true; setStorageIsDirty(true) } setStorage(s => ({ ...s, [k]: v })) }
+
+ const mapApiBackupToUi = useCallback((b: BackupItem): Backup => {
+ const kb = Math.round(b.sizeBytes / 1024)
+ return {
+ id: b.id,
+ server: b.serverName,
+ serverId: b.serverId == null ? null : String(b.serverId),
+ filename: b.filename,
+ size: b.sizeBytes < 1024 ? `${b.sizeBytes} Б` : `${kb} КБ`,
+ sizeBytes: b.sizeBytes,
+ created: new Date(b.createdAt).toLocaleString("ru-RU"),
+ kind: b.kind,
+ notes: b.notes ?? "",
+ storage: b.storage,
+ uploadError: b.uploadError ?? undefined,
+ localFileStatus: b.localFileStatus,
+ }
+ }, [])
+
+ const loadLive = useCallback(async () => {
+ if (!isLive || !canRead || !alive.current) return
+ reader.current?.abort()
+ const abort = new AbortController(), version = ++revision.current
+ reader.current = abort
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)])
+ const active = () => alive.current && !abort.signal.aborted && revision.current === version
+ const success = (resource: Resource) => { if (active()) setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } })) }
+ const failure = (resource: Resource, error: unknown) => { if (active()) setResources(prev => ({ ...prev, [resource]: { ...prev[resource], error: userApiError(error) } })) }
+ setLoading(true)
+ await Promise.allSettled([
+ listBackups(backendUrl, { signal }).then(rows => { if (!active()) return; setBackupList(rows.map(mapApiBackupToUi)); success("backups") }).catch(error => failure("backups", error)),
+ canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => {
+ const rows = serverListSchema.parse(payload)
+ if (!active()) return
+ setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
+ }).catch(error => failure("servers", error)) : Promise.resolve(),
+ getBackupScheduleSettings(backendUrl, { signal }).then(row => {
+ if (!active()) return
+ if (!scheduleDirty.current) { setSchedule(row); setSelectedServers(new Set(row.serverIds)); setAllEnabled(row.serverIds.length === 0) }
+ setSavedSchedule(row); setScheduleLastError(row.lastError ?? null); success("schedule")
+ }).catch(error => failure("schedule", error)),
+ getBackupStorageSettings(backendUrl, { signal }).then(row => {
+ if (!active()) return
+ setSavedStorage(row)
+ if (!storageDirty.current) setStorage(prev => ({ ...prev, ...row, s3SecretAccessKey: "" }))
+ success("storage")
+ }).catch(error => failure("storage", error)),
+ ])
+ if (active()) setLoading(false)
+ }, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
+ useEffect(() => {
+ alive.current = true
+ const downloads = downloadControllers.current
+ queueMicrotask(() => { if (alive.current) void loadLive() })
+ return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
+ }, [loadLive])
+
+ const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
+ const checkpoint = job.checkpoint
+ const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status)
+ const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null
+ const persistedJobError = checkpoint?.job && jobTerminal ? backupJobResultError(checkpoint.job) : null
+ const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
+
+ function startSettingsMutation() {
+ if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
+ settingsLock.current = true
+ const abort = new AbortController()
+ settingsController.current = abort
+ reader.current?.abort(); revision.current += 1; setLoading(false)
+ setScheduleSaveBusy(true); setOpError(null)
+ return abort
+ }
+ function finishSettingsMutation(abort: AbortController) {
+ if (settingsController.current !== abort) return
+ settingsLock.current = false; settingsController.current = null
+ if (alive.current) { setScheduleSaveBusy(false); setTestBusy(false); setSyncBusy(false) }
+ }
+ function settingsConfirmed(resource: Resource) {
+ setResources(prev => ({ ...prev, [resource]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
+ }
+ async function handleSave() {
+ if (!canWrite || !current("schedule") || !current("servers") || !canReadServers) return
+ const abort = startSettingsMutation(); if (!abort) return
+ try {
+ const payload = backupSchedulePayload(schedule, [...selectedServers], allEnabled)
+ if (payload.serverIds?.some(id => !liveServers.some(server => server.id === id))) throw new Error("Выбранный сервер отсутствует; обновите или измените выбор")
+ const result = await putBackupScheduleSettings(backendUrl, payload, { signal: abort.signal })
+ if (!alive.current || abort.signal.aborted) return
+ setSavedSchedule(result); setSchedule(result); setScheduleLastError(result.lastError ?? null); scheduleDirty.current = false
+ setSelectedServers(new Set(result.serverIds)); setAllEnabled(result.serverIds.length === 0); settingsConfirmed("schedule")
+ toast.success("Расписание сохранено")
+ } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
+ finally { finishSettingsMutation(abort) }
+ }
+ async function handleSaveStorage() {
+ if (!canWrite || !current("storage")) return
+ const abort = startSettingsMutation(); if (!abort) return
+ try {
+ const result = await putBackupStorageSettings(backendUrl, backupStoragePayload(storage, savedStorage?.secretConfigured), { signal: abort.signal })
+ if (!alive.current || abort.signal.aborted) return
+ setSavedStorage(result); setStorage(prev => ({ ...prev, ...result, s3SecretAccessKey: "" })); storageDirty.current = false; setStorageIsDirty(false); settingsConfirmed("storage")
+ toast.success("Хранилище сохранено")
+ } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
+ finally { finishSettingsMutation(abort) }
+ }
+ async function handleTestStorage() {
+ if (!canWrite || !current("storage")) return
+ if (storageDirty.current) { setOpError("Сначала сохраните хранилище; проверка использует подтверждённые настройки"); return }
+ if (savedStorage?.provider !== "s3") { setOpError("Проверка доступна для сохранённого хранилища S3"); return }
+ const abort = startSettingsMutation(); if (!abort) return
+ setTestBusy(true)
+ try {
+ const result = await testBackupStorage(backendUrl, { signal: abort.signal })
+ if (!alive.current || abort.signal.aborted) return
+ setSavedStorage(result); settingsConfirmed("storage")
+ if (result.lastTestError || !result.lastTestAt || result.provider !== "s3") throw new Error(result.lastTestError || "Проверка S3 не подтверждена")
+ toast.success("Доступ к S3 подтверждён")
+ } catch (error) {
+ if (alive.current && !abort.signal.aborted) {
+ if (error instanceof ApiClientError && error.payload && typeof error.payload === "object" && "settings" in error.payload) {
+ const parsed = backupStorageSettingsDtoSchema.safeParse(error.payload.settings)
+ if (parsed.success) setSavedStorage(parsed.data)
+ }
+ setOpError(userApiError(error))
+ }
+ }
+ finally { finishSettingsMutation(abort) }
+ }
+ async function handleSyncStorage() {
+ if (!canWrite || !current("storage")) return
+ if (storageDirty.current) { setOpError("Сначала сохраните хранилище; синхронизация использует подтверждённые настройки"); return }
+ if (savedStorage?.provider !== "s3") return
+ const abort = startSettingsMutation(); if (!abort) return
+ setSyncBusy(true)
+ try {
+ const result = await syncBackupsFromStorage(backendUrl, { signal: abort.signal })
+ if (!alive.current || abort.signal.aborted) return
+ toast.success("Импортировано: " + result.imported + ", пропущено: " + result.skipped)
+ await loadLive()
+ } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
+ finally { finishSettingsMutation(abort) }
+ }
+
+ async function handleManualBackup() {
+ if (checkpoint) { await job.run(); return }
+ if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return
+ if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return }
+ await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined })
+ }
+
+ useEffect(() => {
+ if (opError) toast.error(opError)
+ }, [opError])
+
+ function startOperation() {
+ if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
+ operationLock.current = true
+ const abort = new AbortController(); operationController.current = abort
+ reader.current?.abort(); revision.current += 1; setLoading(false)
+ setOpBusy(true); setOpError(null)
+ return abort
+ }
+ function finishOperation(abort: AbortController) {
+ if (operationController.current !== abort) return
+ operationLock.current = false; operationController.current = null
+ if (alive.current) setOpBusy(false)
+ }
+ async function handleDelete() {
+ if (!deleteTarget) return
+ const target = deleteTarget, abort = startOperation(); if (!abort) return
+ try {
+ await deleteBackup(backendUrl, target.id, { signal: abort.signal }, backupFileMissing(target))
+ if (!alive.current || abort.signal.aborted) return
+ setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
+ toast.success("API подтвердил удаление резервной копии")
+ await loadLive()
+ } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
+ finally { finishOperation(abort) }
+ }
+ async function handleRestore() {
+ if (!restoreTarget || !restoreTarget.serverId || !restoreGuard.canStart) return
+ if (backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget)) {
+ setOpError("Локальный файл отсутствует. Создайте новую резервную копию.")
+ return
+ }
+ const target = restoreTarget, abort = startOperation(); if (!abort) return
+ let sent = false
+ try {
+ if (!restoreGuard.begin({ backupId: target.id, serverId: Number(target.serverId), filename: target.filename, serverName: target.server })) return
+ sent = true
+ const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal }, Number(target.serverId))
+ if (!alive.current || abort.signal.aborted) return
+ if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
+ toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
+ restoreGuard.clear(); setRestoreTarget(null)
+ } catch (error) {
+ if (alive.current && !abort.signal.aborted) {
+ if (sent && restoreWasNotApplied(error)) restoreGuard.clear()
+ setOpError(userApiError(error) + (sent && !restoreWasNotApplied(error) ? ". Результат импорта неизвестен; проверьте роутер перед повтором" : ""))
+ }
+ }
+ finally { finishOperation(abort) }
+ }
+ async function handleDownload(id: string, filename: string) {
+ if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
+ const abort = new AbortController(); downloadControllers.current.set(id, abort)
+ try {
+ const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
+ if (!alive.current || abort.signal.aborted) return
+ const url = URL.createObjectURL(blob)
+ try {
+ const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
+ } finally { URL.revokeObjectURL(url) }
+ } catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
+ finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
+ }
+
+ const autoCount = backupList.filter((b) => b.kind === "auto").length
+ const manualCount = backupList.filter((b) => b.kind === "manual").length
+ const s3Count = backupList.filter((b) => b.storage === "s3" || b.storage === "both").length
+
+ const schedSummary = (() => {
+ const confirmed = isLive ? savedSchedule : defaultSchedule
+ if (!confirmed) return "Расписание пока неизвестно"
+ if (!confirmed.enabled) return "Расписание отключено"
+ const time = String(confirmed.hour).padStart(2, "0") + ":" + String(confirmed.minute).padStart(2, "0")
+ if (confirmed.frequency === "daily") return "Каждый день в " + time
+ if (confirmed.frequency === "weekly") return "Каждую неделю (" + WEEK_DAYS[Number(confirmed.weekDay)] + ") в " + time
+ return confirmed.monthDay + "-го числа каждого месяца в " + time
+ })()
+ const confirmedStorage = isLive ? savedStorage : defaultStorageForm
+ const destinationLabel = !confirmedStorage ? "Хранилище пока неизвестно" : confirmedStorage.provider === "s3"
+ ? (confirmedStorage.keepLocalCopy ? "локально и в S3" : "в S3")
+ : "локально (storage/backups)"
+
+ const statusDetail = [
+ backupJobId ? null : `${destinationLabel} · ${known("servers") ? liveServers.length + " серверов в каталоге" : "Серверы пока неизвестны"}`,
+ scheduleLastError,
+ savedStorage?.lastTestError,
+ ].filter(Boolean).join(" · ")
+
+ const alertVariant = backupJobId
+ ? "info"
+ : opError || scheduleLastError || savedStorage?.lastTestError
+ ? "warning"
+ : "default"
+
+ function openCreate(all: boolean) {
+ if (checkpoint) { setManualStep(3); setManualOpen(true); return }
+ if (!canCreate) return
+ setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set())
+ setManualNotes("")
+ setManualStep(1)
+ setManualOpen(true)
+ }
+
+ return (
+
+
+ openCreate(true)}
+ disabled={!canCreate || loading || job.busy}
+ >
+
+ Снять со всех
+
+ openCreate(false)}
+ disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
+ >
+
+ {checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
+
+ >
+ }
+ />
+
+
+
+ {urlError &&
Не удалось прочитать ссылку {urlError} Сбросить контекст }
+ {!isLive &&
Демонстрационные данные. Операции и сохранение недоступны.
}
+ {!canRead &&
Нет права mm:backups:read
}
+ {isLive && !canReadServers &&
Каталог устройств недоступен без mm:servers:read.
}
+ {isLive && canRead &&
+ void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить
+ {loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}
+
}
+ {isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource =>
+ {resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}
+ {({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}
+ )}
+
+ {isLive && (restoreGuard.checkpoint || restoreGuard.error) &&
+ {opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}
+
+ {restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}
+ setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта
+ { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление
+
+ }
+ {opError &&
Операция не подтверждена {opError} }
+ {(job.error || job.storageError || persistedJobError) &&
{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"} {job.error || job.storageError || persistedJobError} }
+
Резервные копии История файлов, расписание и подтверждения операций
+
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "auto",
+ label: "Авто",
+ value: known("backups") ? autoCount : "—",
+ icon:
,
+ iconClassName: "text-info",
+ },
+ {
+ id: "manual",
+ label: "Вручную",
+ value: known("backups") ? manualCount : "—",
+ icon:
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "s3",
+ label: "В S3",
+ value: known("backups") ? s3Count : "—",
+ icon:
,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
+
+
+
+
+ {backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
+
+
+ {backupJobId
+ ? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
+ : statusDetail}
+
+
+ setTab("settings")}>
+ Настройки
+
+
+
+
+
+ setTab(String(v) as PageTab)}
+ className="gap-5"
+ >
+
+ История бэкапов
+ Настройки
+
+
+ {urlError ? null : !canRead ? История недоступна без права чтения
: !known("backups") ? {resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}
: { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }}
+ onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }}
+ onCreate={() => openCreate(false)}
+ canCreate={canCreate}
+ canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
+ canRestore={restoreGuard.canStart}
+ canDownload={isLive && canRead}
+ />}
+
+
+
+ {
+ scheduleDirty.current = true
+ setSelectedServers((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }}
+ onSelectAll={() => { scheduleDirty.current = true; setAllEnabled(false); setSelectedServers(new Set(liveServers.map(s => s.id))) }}
+ onClearServers={() => { scheduleDirty.current = true; setSelectedServers(new Set()) }}
+ onSave={handleSave}
+ onSaveStorage={handleSaveStorage}
+ scheduleKnown={known("schedule")}
+ storageKnown={known("storage")}
+ storageDirty={storageIsDirty}
+ scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId}
+ storageWritable={canWrite && current("storage") && !backupJobId}
+ allEnabled={allEnabled}
+ onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }}
+ onTest={handleTestStorage}
+ onSync={handleSyncStorage}
+ saveBusy={scheduleSaveBusy}
+ testBusy={testBusy}
+ syncBusy={syncBusy}
+ />
+
+
+
+
+
+
+
+ {
+ setManualServers((prev) => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }}
+ onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
+ onClear={() => setManualServers(new Set())}
+ notes={checkpoint?.request.notes ?? manualNotes}
+ onNotesChange={setManualNotes}
+ destinationLabel={destinationLabel}
+ busy={job.busy}
+ locked={!!checkpoint}
+ terminal={jobTerminal}
+ canObserve={canRead}
+ jobId={checkpoint?.request.requestId}
+ jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: checkpoint?.job && backupJobOutcome(checkpoint.job).fullSuccess ? "Завершена" : "Завершена с ограничениями", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"}
+ error={job.error || job.storageError || persistedJobError}
+ result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined}
+ onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }}
+ onSubmit={handleManualBackup}
+ />
+
+ void handleDelete()}
+ onCancel={() => setDeleteTarget(null)}
+ />
+ backup.id === restoreTarget.id) ?? restoreTarget))}
+ error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
+ onConfirm={() => void handleRestore()}
+ onCancel={() => setRestoreTarget(null)}
+ />
+
+ )
+}
diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx
new file mode 100644
index 00000000..551b6574
--- /dev/null
+++ b/app/(main)/bgp/page.tsx
@@ -0,0 +1,870 @@
+"use client"
+
+import { Suspense, useState, useMemo, useEffect } from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { PageHeader } from "@/components/page-header"
+import { DataPageToolbar } from "@/components/data-page-toolbar"
+import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
+import { useBgpUrlState } from "@/hooks/use-bgp-url-state"
+import { QueryFilters } from "@/components/data-grids/query-filters"
+import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
+import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
+import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
+import { bgpCatalogSchema, bgpServersSchema, type BackendBgpSession, type BgpFailure } from "@/lib/bgp/response"
+import { fmtBgpNum, sumBgpCounters } from "@/lib/bgp/helpers"
+import { BGP_AS_NAMES } from "@/lib/bgp/types"
+import { Frame, FramePanel } from "@/components/reui/frame"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { DataPageCard } from "@/components/data-page-card"
+import { Button } from "@/components/ui/button"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { cn } from "@/lib/utils"
+import {
+ RefreshCwIcon, DownloadIcon,
+ ActivityIcon, BarChart3Icon,
+ ArrowDownIcon, ServerIcon,
+ AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
+} from "lucide-react"
+import { useDataSource } from "@/lib/data-source"
+import { requestJson } from "@/shared/api/http-client"
+import { servers as mockServers, type Server } from "@/lib/data"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
+
+// ─── types ────────────────────────────────────────────────────────────────────
+
+type BgpSession = BgpSessionRow
+type BgpTab = "sessions" | "routers" | "analytics"
+
+const AS_NAMES = BGP_AS_NAMES
+// ─── mock data ────────────────────────────────────────────────────────────────
+
+const SESSIONS: BgpSession[] = [
+ // ── srv1 (mt-msk-core-01) ────────────────────────────────────────────
+ {
+ id: "s1", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
+ peerIp: "185.1.1.1", remoteAs: 8359, localAs: 65001,
+ routerId: "185.1.1.1", description: "МТС — upstream transit",
+ state: "Established", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: "14д 6ч 22м", holdTime: 90, keepalive: 30,
+ prefixesRx: 280241, prefixesTx: 42, prefixesActive: 277894,
+ inputMessages: 1842204, outputMessages: 14412,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "ADD-PATH"],
+ lastError: null,
+ },
+ {
+ id: "s2", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
+ peerIp: "77.88.8.1", remoteAs: 13238, localAs: 65001,
+ routerId: "77.88.44.1", description: "Яндекс — IX peering",
+ state: "Established", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: "12д 3ч 11м", holdTime: 90, keepalive: 30,
+ prefixesRx: 11840, prefixesTx: 42, prefixesActive: 11840,
+ inputMessages: 184220, outputMessages: 14100,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
+ lastError: null,
+ },
+ {
+ id: "s3", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
+ peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.2", description: "iBGP → SPB-EDGE",
+ state: "Established", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: "8д 14ч 5м", holdTime: 90, keepalive: 30,
+ prefixesRx: 620, prefixesTx: 280283, prefixesActive: 620,
+ inputMessages: 48210, outputMessages: 512800,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
+ lastError: null,
+ },
+ {
+ id: "s4", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
+ peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.3", description: "iBGP → FRA-EDGE",
+ state: "Established", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: "12д 2ч 18м", holdTime: 90, keepalive: 30,
+ prefixesRx: 512, prefixesTx: 280283, prefixesActive: 512,
+ inputMessages: 41022, outputMessages: 488200,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
+ lastError: null,
+ },
+ {
+ id: "s5", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
+ peerIp: "10.200.2.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.4", description: "iBGP → AMS-EDGE",
+ state: "Active", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: null, holdTime: 90, keepalive: 30,
+ prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
+ inputMessages: 0, outputMessages: 0,
+ capabilities: [],
+ lastError: "Hold timer expired",
+ },
+
+ // ── srv2 (mt-spb-edge-01) ────────────────────────────────────────────
+ {
+ id: "s6", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
+ peerIp: "195.54.55.1", remoteAs: 12389, localAs: 65001,
+ routerId: "195.54.55.1", description: "Ростелеком — upstream",
+ state: "Established", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: "9д 1ч 44м", holdTime: 90, keepalive: 30,
+ prefixesRx: 192440, prefixesTx: 42, prefixesActive: 191002,
+ inputMessages: 982440, outputMessages: 12200,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
+ lastError: null,
+ },
+ {
+ id: "s7", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
+ peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
+ state: "Established", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: "8д 14ч 3м", holdTime: 90, keepalive: 30,
+ prefixesRx: 280283, prefixesTx: 620, prefixesActive: 277000,
+ inputMessages: 512800, outputMessages: 48210,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
+ lastError: null,
+ },
+
+ // ── srv3 (mt-fra-edge-01) ────────────────────────────────────────────
+ {
+ id: "s8", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
+ peerIp: "91.108.4.1", remoteAs: 24940, localAs: 65001,
+ routerId: "91.108.4.1", description: "Hetzner — upstream",
+ state: "Established", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: "21д 7ч 12м", holdTime: 90, keepalive: 30,
+ prefixesRx: 218820, prefixesTx: 42, prefixesActive: 215400,
+ inputMessages: 1184200, outputMessages: 11800,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Graceful Restart"],
+ lastError: null,
+ },
+ {
+ id: "s9", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
+ peerIp: "193.188.128.1", remoteAs: 6777, localAs: 65001,
+ routerId: "193.188.128.1", description: "AMS-IX — peering (idle)",
+ state: "Idle", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: null, holdTime: 90, keepalive: 30,
+ prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
+ inputMessages: 821, outputMessages: 412,
+ capabilities: [],
+ lastError: "Administratively down",
+ },
+ {
+ id: "s10", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
+ peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
+ state: "Established", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: "12д 2ч 14м", holdTime: 90, keepalive: 30,
+ prefixesRx: 280283, prefixesTx: 512, prefixesActive: 278100,
+ inputMessages: 488200, outputMessages: 41022,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
+ lastError: null,
+ },
+
+ // ── srv6 (mt-ams-test-01) ────────────────────────────────────────────
+ {
+ id: "s11", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
+ peerIp: "80.249.208.1", remoteAs: 6777, localAs: 65001,
+ routerId: "0.0.0.0", description: "AMS-IX — negotiating",
+ state: "OpenSent", type: "eBGP", afi: "IPv4 Unicast",
+ uptime: null, holdTime: 90, keepalive: 30,
+ prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
+ inputMessages: 2, outputMessages: 1,
+ capabilities: [],
+ lastError: null,
+ },
+ {
+ id: "s12", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
+ peerIp: "10.200.5.1", remoteAs: 65001, localAs: 65001,
+ routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
+ state: "Established", type: "iBGP", afi: "IPv4 Unicast",
+ uptime: "5д 9ч 17м", holdTime: 90, keepalive: 30,
+ prefixesRx: 280283, prefixesTx: 14, prefixesActive: 276000,
+ inputMessages: 184200, outputMessages: 4100,
+ capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
+ lastError: null,
+ },
+]
+
+// ─── visual helpers ───────────────────────────────────────────────────────────
+
+const STATE_STYLE: Record = {
+ Unknown: { bg: "bg-muted", text: "text-muted-foreground", dot: "bg-muted-foreground", label: "Неизвестно" },
+ Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
+ Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
+ Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
+ Connect: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", dot: "bg-blue-500", label: "Connect" },
+ OpenSent: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenSent" },
+ OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
+}
+const TYPE_STYLE: Record = {
+ Unknown: { bg: "bg-muted", text: "text-muted-foreground" },
+ eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
+ iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
+}
+
+function TypeBadge({ type }: { type: BgpType }) {
+ const s = TYPE_STYLE[type]
+ return (
+
+ {type === "Unknown" ? "Тип неизвестен" : type}
+
+ )
+}
+
+
+const fmtNum = fmtBgpNum
+
+// ─── backend mapping ──────────────────────────────────────────────────────────
+
+type BackendServer = ReturnType[number]
+type InventoryServer = Server & { monitoringUnknown?: boolean }
+function mapBackendServer(s: BackendServer): InventoryServer {
+ return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site ?? "",
+ country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled,
+ status: s.status ?? "offline", monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0 }
+}
+
+function backendToFrontend(b: BackendBgpSession): BgpSession {
+ return {
+ id: `${b.serverId}-${b.id}`,
+ serverId: String(b.serverId),
+ serverLabel: b.serverName,
+ serverSite: b.serverSite,
+ peerIp: b.peerIp,
+ remoteAs: b.remoteAs,
+ localAs: b.localAs,
+ routerId: b.remoteId || b.localId,
+ description: b.name,
+ state: Object.hasOwn(STATE_STYLE, b.state) ? b.state as BgpState : "Unknown",
+ type: b.type,
+ afi: b.afi ?? "Неизвестно",
+ uptime: b.uptime,
+ holdTime: b.holdTime,
+ keepalive: b.keepalive,
+ prefixesRx: b.prefixesRx,
+ prefixesTx: b.prefixesTx,
+ prefixesActive: null,
+ inputMessages: b.inputMessages,
+ outputMessages: b.outputMessages,
+ capabilities: b.capabilities,
+ lastError: b.lastError,
+ }
+}
+
+// ─── sessions tab ─────────────────────────────────────────────────────────────
+
+const BGP_FILTER_ACCESSORS = {
+ state: (s: BgpSession) => s.state,
+ type: (s: BgpSession) => s.type,
+ afi: (s: BgpSession) => s.afi,
+}
+
+function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
+ const { search, setSearch, stateFilter, setStateFilter, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset } = useBgpUrlState()
+ const fields = useMemo(() => BGP_FILTER_FIELDS.map(field => field.id === "afi" ? { ...field, options: Array.from(new Set(sessions.map(s => s.afi))).sort().map(value => ({ value, label: value })) } : field), [sessions])
+ const q = search.toLowerCase()
+ const filtered = useMemo(() => {
+ if (urlError) return []
+ const base = sessions.filter((s) => {
+ if (stateFilter !== "all" && s.state !== stateFilter) return false
+ if (typeFilter !== "all" && s.type !== typeFilter) return false
+ if (
+ q &&
+ !s.peerIp.includes(q) &&
+ !s.description.toLowerCase().includes(q) &&
+ !s.serverLabel.toLowerCase().includes(q) &&
+ !String(s.remoteAs).includes(q) &&
+ !(s.remoteAs === null ? "" : AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
+ ) {
+ return false
+ }
+ return true
+ })
+ return applyFilterQuery(base, advancedFilters, BGP_FILTER_ACCESSORS)
+ }, [sessions, q, stateFilter, typeFilter, advancedFilters, urlError])
+
+ return (
+
+ {urlError &&
{urlError}Сбросить фильтры BGP }
+
+ s.state === "Unknown").length },
+ { value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
+ { value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
+ { value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
+ { value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
+ ],
+ }}
+ filterControl={ }
+ search={search}
+ onSearchChange={setSearch}
+ searchPlaceholder="IP, AS, описание…"
+ countLabel={`${filtered.length} из ${sessions.length}`}
+ actions={
+
+ {(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
+ setTypeFilter(t)}
+ aria-pressed={typeFilter === t}
+ className={cn(
+ "px-2.5 py-1 text-[11px] rounded transition-colors",
+ typeFilter === t
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
+
+ ))}
+
+ }
+ />
+ {selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && Сессия из ссылки скрыта текущими фильтрами.
}
+ {hasFilters && !urlError && Сбросить фильтры BGP }
+
+
+
+ )
+}
+
+// ─── routers tab ──────────────────────────────────────────────────────────────
+
+function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
+ const byRouter = useMemo(() => {
+ const map: Record = {}
+ sessions.forEach(s => {
+ if (!map[s.serverId]) map[s.serverId] = { label: s.serverLabel, site: s.serverSite, sessions: [] }
+ map[s.serverId].sessions.push(s)
+ })
+ return Object.entries(map).map(([id, v]) => ({ id, ...v }))
+ }, [sessions])
+
+ return (
+
+ {byRouter.map(router => {
+ const estCnt = router.sessions.filter(s => s.state === "Established").length
+ const downCnt = router.sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
+ const totalRx = sumBgpCounters(router.sessions.map(s => s.prefixesRx))
+
+ return (
+
+
+ {/* header */}
+
+
+
+
{router.label}
+
{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}
+
+
+ {estCnt > 0 && (
+
+ ✓ {estCnt}
+
+ )}
+ {downCnt > 0 && (
+
+ ⚠ {downCnt}
+
+ )}
+
+
+
+ {/* session rows */}
+
+ {router.sessions.map(s => {
+ const ss = STATE_STYLE[s.state]
+ return (
+
+
+
+
+ {s.peerIp}
+
+
+
+ {s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}
+ {s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
+ · {AS_NAMES[s.remoteAs]}
+ )}
+
+
+
+ {s.prefixesRx !== null && s.prefixesRx > 0 && (
+
+ ↓ {fmtNum(s.prefixesRx)}
+
+ )}
+
{ss.label}
+
+
+ )
+ })}
+
+
+ {/* footer summary */}
+ {totalRx !== null && totalRx > 0 && (
+
+
+
+ Всего получено: {fmtNum(totalRx)} префиксов
+
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+// ─── analytics tab ────────────────────────────────────────────────────────────
+
+function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
+ // top peers by prefixes received (eBGP only)
+ const topPeers = useMemo(() =>
+ (sessions.some(s => s.prefixesRx === null) ? [] : [...sessions])
+ .filter(s => s.prefixesRx !== null && s.prefixesRx > 0)
+ .sort((a, b) => (b.prefixesRx ?? 0) - (a.prefixesRx ?? 0))
+ .slice(0, 8),
+ [sessions]
+ )
+ const maxRx = topPeers[0]?.prefixesRx ?? 1
+
+ // state distribution
+ const stateCounts = useMemo(() => {
+ const map: Record = {}
+ sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
+ return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
+ }, [sessions])
+
+ // total prefix stats
+ const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
+ const totalActive = sumBgpCounters(sessions.map(s => s.prefixesActive))
+ const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
+ const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
+
+ return (
+
+
,
+ iconClassName: "text-success",
+ },
+ {
+ id: "active",
+ label: "Маршруты",
+ hint: totalActive === null ? "нет данных API" : undefined,
+ value: fmtNum(totalActive),
+ icon:
,
+ iconClassName: "text-info",
+ },
+ {
+ id: "ebgp",
+ label: "eBGP",
+ value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
+ icon:
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "ibgp",
+ label: "iBGP",
+ value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
+ icon:
,
+ iconClassName: "text-muted-foreground",
+ },
+ ]}
+ />
+
+
+ {/* prefixes by peer — horizontal bar chart */}
+
+
+ {topPeers.map((s, i) => {
+ const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
+ const ss = STATE_STYLE[s.state]
+ return (
+
+
+ {i + 1}
+
+
+
+
+ {s.peerIp}
+ {s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}
+
+
+
+ {fmtNum(s.prefixesRx)}
+
+
+
+
{s.serverLabel}
+
+
+ )
+ })}
+ {topPeers.length === 0 && (
+
Недостаточно данных о префиксах для сравнения
+ )}
+
+
+
+ {/* right column */}
+
+ {/* state distribution */}
+
+ {stateCounts.length === 0 ? (
+ Нет данных
+ ) : (
+
+ {stateCounts.map(([state, count]) => {
+ const ss = STATE_STYLE[state]
+ const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
+ return (
+
+
+
{ss.label}
+
+
{count}
+
+ )
+ })}
+
+ )}
+
+
+ {/* eBGP vs iBGP */}
+
+ {sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
+ Нет данных
+ ) : (
+ <>
+ {[
+ { type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
+ { type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
+ ].map(({ type, count }) => {
+ const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
+ return (
+
+ )
+ })}
+
+ {/* active AS list */}
+
+
Автономные системы
+
+ {Object.entries(
+ sessions.filter(s => s.type === "eBGP").reduce
>((acc, s) => {
+ if (s.remoteAs !== null) acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
+ }, {})
+ ).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
+
+ AS{as}
+ {AS_NAMES[Number(as)] ?? ""}
+ {cnt}
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+ )
+}
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
+const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
+ { id: "sessions", label: "Сессии", icon: },
+ { id: "routers", label: "По роутерам", icon: },
+ { id: "analytics", label: "Аналитика", icon: },
+]
+
+export default function BgpPage() {
+ return Загрузка BGP…}>
+}
+function BgpRoute() {
+ const { mode, backendUrl } = useDataSource()
+ const params = useSearchParams()
+ const router = useRouter()
+ const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
+ const selectedSessionId = params.get("session")
+ const tab = params.get("tab")
+ const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
+ function navigate(values: Record) {
+ const next = new URLSearchParams(params.toString())
+ for (const [key, value] of Object.entries(values)) {
+ if (value === null) next.delete(key)
+ else next.set(key, value)
+ }
+ router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
+ }
+ return navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
+ onSelectSession={id => navigate({ session: id })}
+ onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
+}
+function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
+ selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
+ onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
+}) {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+
+ const [liveSessions, setLiveSessions] = useState([])
+ const [liveServers, setLiveServers] = useState([])
+ const [loading, setLoading] = useState(isLive)
+ const [fetchedAt, setFetchedAt] = useState(null)
+ const [liveError, setLiveError] = useState(null)
+ const [failures, setFailures] = useState([])
+ const [hasLoaded, setHasLoaded] = useState(false)
+ const [readReceipts, setReadReceipts] = useState>({})
+ const [fetchTick, setFetchTick] = useState(0)
+
+ useEffect(() => {
+ if (!isLive) {
+ queueMicrotask(() => {
+ setLiveSessions([])
+ setLiveServers([])
+ setLiveError(null)
+ })
+ return
+ }
+ let cancelled = false
+ queueMicrotask(() => {
+ if (cancelled) return
+ setLoading(true)
+ void Promise.all([
+ requestJson(backendUrl, "/api/bgp/catalog"),
+ requestJson(backendUrl, "/api/servers"),
+ ])
+ .then(([data, servers]) => {
+ if (cancelled) return
+ const catalog = bgpCatalogSchema.parse(data)
+ const inventory = bgpServersSchema.parse(servers)
+ const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
+ if (catalog.sessions.some(s => !enabled.has(s.serverId)) || catalog.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст BGP-каталога")
+ const failed = new Set(catalog.failures.map(f => String(f.serverId)))
+ setLiveSessions(previous => [...catalog.sessions.map(backendToFrontend), ...previous.filter(s => enabled.has(Number(s.serverId)) && failed.has(s.serverId))])
+ setLiveServers(inventory.filter(s => s.enabled).map(mapBackendServer))
+ setFailures(catalog.failures)
+ setLiveError(null)
+ setHasLoaded(true)
+ const receivedAt = new Date()
+ setFetchedAt(receivedAt)
+ setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
+ setLoading(false)
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return
+ setLiveError(err instanceof Error ? err.message : String(err))
+ setLoading(false)
+ })
+ })
+ return () => { cancelled = true }
+ }, [isLive, backendUrl, fetchTick])
+
+ const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
+ const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
+ const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
+ const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
+ const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
+
+ const effectiveServerId = selectedServerId
+
+ const sessions = useMemo(() => {
+ if (effectiveServerId === ALL_SERVERS_ID) return allSessions
+ return allSessions.filter((s) => s.serverId === effectiveServerId)
+ }, [allSessions, effectiveServerId])
+
+ const railItems = useMemo(() => {
+ const counts = new Map()
+ for (const s of allSessions) {
+ counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
+ }
+ return displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: "monitoringUnknown" in s && s.monitoringUnknown ? undefined : s.status,
+ meta: "monitoringUnknown" in s && s.monitoringUnknown ? "Статус неизвестен" : undefined,
+ type: s.type,
+ count: isLive && (!hasLoaded || unknownIds.has(s.id)) ? undefined : counts.get(s.id) ?? 0,
+ enabled: s.enabled,
+ title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
+ }))
+ }, [displayServers, allSessions, hasLoaded, isLive, unknownIds])
+
+ const established = sessions.filter(s => s.state === "Established").length
+ const notEstab = sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").length
+ const totalRx = sumBgpCounters(sessions.map(s => s.prefixesRx))
+
+
+ return (
+
+
+ setFetchTick(t => t + 1)}>
+
+ Обновить
+
+ >
+ }
+ />
+ }
+ banner={
+
+
+ {TABS.map(t => (
+ onSelectTab(t.id)}
+ className={cn(
+ "flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
+ activeTab === t.id
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
+ )}>
+ {t.icon}{t.label}
+
+ ))}
+
+
+ }
+ >
+
+
+ {unavailableContext &&
Устройство BGP недоступно в этом каталоге Контекст {selectedServerId} сохранён. Выберите доступное устройство. onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP }
+ {selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) &&
Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён. onSelectSession(null)}>Все сессии BGP }
+ {/* data source banner */}
+ {isLive && loading && (
+
+
+ Загрузка BGP-сессий…
+
+ )}
+ {isLive && fetchedAt && (
+
+ Ответ каталога получен
{fetchedAt.toLocaleTimeString("ru")} .
+ Это время получения браузером, не время измерения RouterOS.
+ {displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s =>
{s.name}: {readReceipts[s.id] ? {new Date(readReceipts[s.id]).toLocaleTimeString("ru")} : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}
)}
+
+ )}
+ {isLive && (liveError || failures.length > 0) && (
+
+
+ {liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}
+
+ {liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
+ setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить
+
+
+ )}
+ {mode === "mock" && (
+
+ Моковые данные
+
+ )}
+
+
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "established",
+ label: "Established",
+ value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
+ icon:
,
+ iconClassName: "text-success",
+ },
+ {
+ id: "not-estab",
+ label: "Не поднято",
+ value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
+ icon:
,
+ iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
+ variant: notEstab > 0 ? "warning" : "default",
+ },
+ {
+ id: "prefixes",
+ label: "Префиксы",
+ value: incomplete ? "—" : fmtNum(totalRx),
+ icon:
,
+ iconClassName: "text-info",
+ },
+ ]}
+ />
+
+ {/* alert: not-established sessions */}
+ {!incomplete && notEstab > 0 && (
+
+
+
+ {notEstab} сессии не в состоянии Established
+
+
+ Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
+
+
+ )}
+
+ {/* tab content */}
+ {activeTab === "sessions" &&
}
+ {activeTab === "routers" && (incomplete ?
Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.
:
)}
+ {activeTab === "analytics" && (incomplete ?
Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.
:
)}
+
+
+
+ )
+}
diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx
new file mode 100644
index 00000000..d2b28d70
--- /dev/null
+++ b/app/(main)/certificates/page.tsx
@@ -0,0 +1,675 @@
+"use client"
+
+import { Suspense, useMemo, useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
+import { PageHeader } from "@/components/page-header"
+import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
+import { routerCertificates, servers as mockServers } from "@/lib/data"
+import type { Server } from "@/lib/data"
+import type { CertificateDto } from "@mmapp/contracts/certificates"
+import { OpsPanel } from "@/components/ops-panel"
+import { DataPageCard } from "@/components/data-page-card"
+import { DataPageToolbar } from "@/components/data-page-toolbar"
+import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
+import { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
+import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
+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 { cn } from "@/lib/utils"
+import { useDataSource } from "@/lib/data-source"
+import { can } from "@/lib/auth"
+import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
+import type { CertificateView } from "@/lib/certificate-view"
+import { toFrontendServer } from "@/entities/server/model/mappers"
+import { useCertificateIssue } from "@/lib/use-certificate-issue"
+import { toast } from "sonner"
+import {
+ ShieldCheckIcon,
+ BadgeCheckIcon,
+ AlertTriangleIcon,
+ AlertCircleIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ UploadIcon,
+} from "lucide-react"
+import {
+ Stepper,
+ StepperContent,
+ StepperIndicator,
+ StepperItem,
+ StepperNav,
+ StepperPanel,
+ StepperSeparator,
+ StepperTitle,
+ StepperTrigger,
+} from "@/components/reui-kit/accessible-stepper"
+
+function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
+ return {
+ id: cert.id,
+ name: cert.name,
+ serverId: cert.serverId,
+ commonName: cert.commonName,
+ sans: cert.sans,
+ issuedBy: cert.issuedBy,
+ validFrom: cert.validFrom,
+ validUntil: cert.validUntil,
+ daysLeft: cert.daysLeft,
+ keySize: cert.keySize,
+ usage: cert.usage,
+ trusted: cert.trusted,
+ status: cert.status,
+ }
+}
+
+function CertPartAlertExpired({ expired }: { expired: CertificateView[] }) {
+ return (
+
+
+
+
+ {expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
+
+
+ {expired.map((c) => c.name).join(", ")} — требуют обновления
+
+
+
+ )
+}
+
+function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
+ return (
+
+
+
+
+ {expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
+
+
+ {expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
+
+
+
+ )
+}
+
+function CertPartKpi({
+ displayCerts,
+ expiring,
+ expired,
+ complete,
+ totalKnown,
+}: {
+ displayCerts: CertificateView[]
+ expiring: CertificateView[]
+ expired: CertificateView[]
+ complete: boolean
+ totalKnown: boolean
+}) {
+ return (
+ ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "valid",
+ label: "Действующих",
+ value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
+ icon: ,
+ iconClassName: "text-success",
+ },
+ {
+ id: "expiring",
+ label: "Истекают",
+ value: complete ? expiring.length : "—",
+ icon: ,
+ iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
+ variant: expiring.length > 0 ? "warning" : "default",
+ },
+ {
+ id: "expired",
+ label: "Истёкших",
+ value: complete ? expired.length : "—",
+ icon: ,
+ iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
+ variant: expired.length > 0 ? "destructive" : "default",
+ },
+ ]}
+ />
+ )
+}
+
+function CertPartReference() {
+ return (
+
+
+ {[
+ {
+ title: "Создать CA",
+ lines: [
+ "/certificate add \\",
+ " name=my-ca \\",
+ " common-name=MyCA \\",
+ " key-size=4096 \\",
+ " days-valid=3650 \\",
+ " key-usage=key-cert-sign,crl-sign",
+ "/certificate sign my-ca",
+ ],
+ },
+ {
+ title: "Импорт LE",
+ lines: [
+ "/certificate import \\",
+ " file-name=router.crt \\",
+ " name=router-cert \\",
+ " trusted=yes \\",
+ " trust-store=www,api",
+ ],
+ },
+ {
+ title: "Статус",
+ lines: ["/certificate print detail", "/certificate export-certificate router-cert"],
+ },
+ ].map((b) => (
+
+
+ {b.title}
+
+
+ {b.lines.join("\n")}
+
+
+ ))}
+
+
+ )
+}
+
+function CertPartIssueForm({
+ serverList,
+ issueServerId,
+ setIssueServerId,
+ issueCertName,
+ setIssueCertName,
+ issueCommonName,
+ setIssueCommonName,
+ issueSans,
+ setIssueSans,
+ issueTrustWww,
+ setIssueTrustWww,
+ issueTrustApi,
+ setIssueTrustApi,
+ step,
+}: {
+ serverList: Server[]
+ issueServerId: string
+ setIssueServerId: (v: string) => void
+ issueCertName: string
+ setIssueCertName: (v: string) => void
+ issueCommonName: string
+ setIssueCommonName: (v: string) => void
+ issueSans: string
+ setIssueSans: (v: string) => void
+ issueTrustWww: boolean
+ setIssueTrustWww: (v: boolean) => void
+ issueTrustApi: boolean
+ setIssueTrustApi: (v: boolean) => void
+ step?: 1 | 2 | 3 | 4
+}) {
+ const showAll = step == null
+ return (
+
+ {(showAll || step === 1) && (
+
+ Основные
+
+ setIssueServerId(e.target.value)}
+ className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
+ >
+
+ Выбрать сервер…
+
+ {serverList.map((s) => (
+
+ {s.name} ({s.site})
+
+ ))}
+
+
+
+ setIssueCertName(e.target.value)}
+ placeholder="router-le"
+ />
+
+
+ )}
+
+ {(showAll || step === 2) && (
+
+
Домены
+
+ setIssueCommonName(e.target.value)}
+ placeholder="vpn.example.com"
+ />
+
+
+
+
+
Let's Encrypt · DNS-01 (Cloudflare)
+
TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.
+
+
+ )}
+
+ {(showAll || step === 3) && (
+
+
Импорт на RouterOS
+
+
+
Trust store · www
+
Веб-интерфейс и HTTPS-сервисы
+
+
+
+
+
+
Trust store · api
+
REST API и управление
+
+
+
+
После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.
+
+ )}
+
+ {(showAll || step === 4) && (
+
+
Проверьте параметры
+
+ Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}
+ Имя: {issueCertName || "—"}
+ CN: {issueCommonName || "—"}
+ Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}
+ Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.
+ DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.
+
+
+ )}
+
+ )
+}
+
+export default function CertificatesPage() {
+ return Загрузка контекста сертификатов…}>
+}
+
+function CertificatesSource() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ if (!prefsHydrated) return Загрузка настроек источника…
+ return
+}
+
+function CertificatesContent() {
+ const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
+ const isLive = prefsHydrated && mode === "live"
+ const liveReady = isLive && backendStatus === true
+
+ const params = useSearchParams()
+ const context = readCertificatesUrl(params)
+ const search = context.search
+ const statusFilter = context.status
+ const selectedServerId = context.server
+ const urlError = context.error
+ function writeContext(changes: Record, replace = false) {
+ const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
+ if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
+ }
+ const setSearch = (value: string) => writeContext({ q: value }, true)
+ const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
+ const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
+ const canRead = can("mm:certificates:read")
+ const canWrite = can("mm:certificates:write")
+ const canReadServers = can("mm:servers:read")
+ const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
+ const { snapshots, loading, error: loadError, serverError } = catalog
+ const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
+
+ const [issueOpen, setIssueOpen] = useState(false)
+ const [issueStep, setIssueStep] = useState(1)
+ const [issueServerId, setIssueServerId] = useState("")
+ const [issueCertName, setIssueCertName] = useState("")
+ const [issueCommonName, setIssueCommonName] = useState("")
+ const [issueSans, setIssueSans] = useState("")
+ const [issueTrustWww, setIssueTrustWww] = useState(true)
+ const [issueTrustApi, setIssueTrustApi] = useState(true)
+
+
+ const displayCerts = useMemo(() => {
+ if (!prefsHydrated) return []
+ if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
+ return routerCertificates.map(mockToDto)
+ }, [prefsHydrated, isLive, snapshots])
+
+ const displayServers = isLive ? serverList : mockServers
+
+ const scopedCerts = useMemo(() => {
+ if (urlError) return []
+ if (selectedServerId === ALL_SERVERS_ID) return displayCerts
+ return displayCerts.filter((c) => c.serverId === selectedServerId)
+ }, [displayCerts, selectedServerId, urlError])
+
+ const certRailItems = useMemo(() => (
+ displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
+ type: s.type,
+ enabled: s.enabled,
+ meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
+ }))
+ ), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
+
+ const serverById = useMemo(() => {
+ const map = new Map()
+ for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
+ return map
+ }, [isLive, serverList])
+
+ const loadLive = catalog.reload
+ const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
+ const issueBusy = issue.busy
+ const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
+
+ const expiring = useMemo(
+ () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
+ [scopedCerts],
+ )
+ const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
+
+ const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
+ const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
+ const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
+
+ const filtered = useMemo(() => {
+ return scopedCerts.filter((c) => {
+ if (statusFilter !== "all" && c.status !== statusFilter) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return (
+ c.name.toLowerCase().includes(q) ||
+ c.commonName.toLowerCase().includes(q) ||
+ c.issuedBy.toLowerCase().includes(q) ||
+ c.sans.some((s) => s.includes(q))
+ )
+ })
+ }, [scopedCerts, search, statusFilter])
+
+ async function handleRefresh() {
+ await loadLive()
+ }
+
+ async function handleIssue() {
+ if (issue.checkpoint) { await issue.run(); return }
+ if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
+ const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
+ if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
+ await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
+ }
+ function startNewIssue() {
+ if (!issue.reset()) return
+ setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
+ }
+
+ return (
+ <>
+
+
+ {
+ void handleRefresh()
+ }}
+ >
+
+ Обновить
+
+
+
+ Импорт
+
+ {
+ setIssueStep(issue.checkpoint ? 4 : 1)
+ if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
+ setIssueOpen(true)
+ }}
+ >
+
+ {issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
+
+ >
+ }
+ />
+ }
+ >
+
+ {!isLive &&
Моковые данные
}
+ {urlError &&
{urlError} writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст
}
+ {!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) &&
Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.
}
+ {isLive && backendStatus === false && (
+
+ Backend недоступен — live-операции отключены.
+
+ )}
+ {loadError && isLive && (
+
+ {catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
+
+ )}
+
+ {isLive && serverError &&
{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}
}
+ {isLive && !canRead &&
Нет права mm:certificates:read.
}
+ {isLive && !canReadServers &&
Нет права mm:servers:read. Выбор устройства для выпуска недоступен.
}
+ {isLive && catalog.collectedAt &&
Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}
}
+ {isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot =>
+ {snapshot.latest.error &&
{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}
}
+
{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}
+
)}
+
+ {(expiring.length > 0 || expired.length > 0) && (
+
+ {expired.length > 0 && }
+ {expiring.length > 0 && }
+
+ )}
+
+ {!classificationsKnown &&
Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.
}
+
+
+
+
+
+
+
+ }
+ countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
+ />
+ {urlError ? Исправьте контекст ссылки
: isLive && scopedCerts.length === 0 && !scopeKnown ? {loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}
: }
+
+
+
+
+
+
+ { setIssueOpen(v); if (!v) setIssueStep(1) }}>
+
+
+ Выпуск сертификата
+
+ Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
+
+
+
+
+ {(issue.error || issue.storageError) &&
{issue.error || issue.storageError}
}
+ {issue.checkpoint &&
Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.
}
+ {issue.checkpoint &&
Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.
}
+
+
+
+ {[
+ { step: 1, title: "Основные" },
+ { step: 2, title: "Домены" },
+ { step: 3, title: "Импорт" },
+ { step: 4, title: "Проверка" },
+ ].map(({ step, title }, i, arr) => (
+
+
+ {step}
+ {title}
+
+ {i < arr.length - 1 && }
+
+ ))}
+
+
+ {[1, 2, 3, 4].map((s) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ {issueTerminal && canWrite && Новый выпуск }
+ }>
+ {issue.checkpoint ? "Закрыть" : "Отмена"}
+
+ {issueStep > 1 && (
+ setIssueStep((s) => s - 1)}>
+ Назад
+
+ )}
+ {issueStep < 4 ? (
+ setIssueStep((s) => s + 1)}
+ >
+ Далее
+
+ ) : (
+ { void handleIssue() }}
+ >
+ {issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx
new file mode 100644
index 00000000..4605621d
--- /dev/null
+++ b/app/(main)/communities/page.tsx
@@ -0,0 +1,315 @@
+"use client"
+
+import { useEffect, useRef, useState, useMemo } from "react"
+import { PageHeader } from "@/components/page-header"
+import { DataPageCard } from "@/components/data-page-card"
+import {
+ CommunitiesDataGrid,
+ type CommunityRow,
+ TYPE_LABELS,
+ ACTION_LABELS,
+} from "@/components/data-grids/communities-data-grid"
+import { Frame, FramePanel } from "@/components/reui/frame"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ PlusIcon, SearchIcon, TagIcon, FilterIcon,
+ CopyIcon, CheckIcon, TrashIcon, PencilIcon,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { filters } from "@/lib/data"
+import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
+import { useEvoBGP } from "@/lib/evobgp-context"
+
+// ─── types ────────────────────────────────────────────────────────────────────
+
+type Community = CommunityRow
+type CommType = CommunityRow["type"]
+
+// ─── mock data ────────────────────────────────────────────────────────────────
+
+const COMMUNITIES: Community[] = [
+ {
+ id: "c1", value: "65001:100", name: "youtube-bypass",
+ description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes",
+ type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842,
+ action: "local-pref", actionValue: 200, enabled: true,
+ },
+ {
+ id: "c2", value: "65001:200", name: "streaming-eu",
+ description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS",
+ type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614,
+ action: "local-pref", actionValue: 180, enabled: true,
+ },
+ {
+ id: "c3", value: "65001:300", name: "cdn-bypass",
+ description: "Обход CDN-провайдеров через прямые пиринговые IX-точки",
+ type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218,
+ action: "local-pref", actionValue: 210, enabled: true,
+ },
+ {
+ id: "c4", value: "65002:100", name: "cdn-secondary",
+ description: "Резервный CDN-путь при деградации основного",
+ type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842,
+ action: "metric", actionValue: 50, enabled: true,
+ },
+ {
+ id: "c5", value: "65001:400", name: "office-direct",
+ description: "Office 365 / Teams — прямой выход без туннелирования",
+ type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882,
+ action: "permit", enabled: true,
+ },
+ {
+ id: "c6", value: "65001:500", name: "gaming-ll",
+ description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)",
+ type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212,
+ action: "local-pref", actionValue: 250, enabled: true,
+ },
+ {
+ id: "c7", value: "65002:200", name: "gaming-ll-backup",
+ description: "Резервный путь для gaming-low-latency",
+ type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412,
+ action: "metric", actionValue: 80, enabled: true,
+ },
+ {
+ id: "c8", value: "65007:999", name: "school-block",
+ description: "Блокировка соцсетей для школьного сегмента LAN",
+ type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412,
+ action: "deny", enabled: false,
+ },
+ {
+ id: "c9", value: "no-export", name: "no-export",
+ description: "Не объявлять маршрут за пределы AS (RFC 1997)",
+ type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0,
+ action: "permit", enabled: true,
+ },
+ {
+ id: "c10", value: "no-advertise", name: "no-advertise",
+ description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)",
+ type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0,
+ action: "permit", enabled: true,
+ },
+]
+
+// ─── page ─────────────────────────────────────────────────────────────────────
+
+export default function CommunitiesPage() {
+ const { sourceKey } = useEvoBGP()
+ return
+}
+function CommunitiesContent() {
+ const { mode, snapshot, loading, known } = useEvoCatalogView()
+
+ const [search, setSearch] = useState("")
+ const [typeFilter, setTypeFilter] = useState("all")
+ const [copied, setCopied] = useState(null)
+ const [copying, setCopying] = useState(false)
+ const [copyError, setCopyError] = useState(null)
+ const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType | null })
+ useEffect(() => {
+ const state = copyRequest.current; state.active = true
+ return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
+ }, [])
+ const [selectedId, setSelectedId] = useState(null)
+
+ const listData = useMemo((): Community[] => {
+ if (mode === "mock") return COMMUNITIES
+ if (loading && !snapshot) return []
+ return snapshot?.communities ?? []
+ }, [mode, loading, snapshot])
+
+ const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
+
+ const filtered = useMemo(() => {
+ const q = search.toLowerCase()
+ return listData.filter(c => {
+ const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
+ const matchT = typeFilter === "all" || c.type === typeFilter
+ return matchQ && matchT
+ })
+ }, [search, typeFilter, listData])
+
+ const handleCopy = async (value: string) => {
+ const state = copyRequest.current
+ if (!state.active || state.pending) return
+ state.pending = true; const revision = ++state.revision
+ if (state.timer) clearTimeout(state.timer)
+ const current = () => state.active && state.revision === revision
+ setCopying(true); setCopied(null); setCopyError(null)
+ try {
+ await navigator.clipboard.writeText(value)
+ if (!current()) return
+ setCopied(value)
+ state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
+ } catch {
+ if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
+ } finally {
+ state.pending = false
+ if (current()) setCopying(false)
+ }
+ }
+
+ const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
+
+ return (
+
+
+ Добавить
+
+ }
+ />
+
+
+
+
+ {copyError &&
Community не скопировано {copyError} }
+ {copying &&
Копирование community…
}
+ {copied &&
Community {copied} скопировано
}
+
+
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "enabled",
+ label: "Активных",
+ value: known ? String(listData.filter((c) => c.enabled).length) : "—",
+ icon:
,
+ iconClassName: "text-success",
+ },
+ {
+ id: "standard",
+ label: "Стандартных",
+ value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
+ icon:
,
+ iconClassName: "text-info",
+ },
+ {
+ id: "filters",
+ label: "Использует фильтры",
+ value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
+ icon:
,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
+
+
+ {/* ── main table ── */}
+
+ {/* toolbar */}
+
+
+
+ setSearch(e.target.value)} />
+
+
+ {(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
+ setTypeFilter(t)}>
+ {t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
+
+ ))}
+
+
+
+ {/* list */}
+
+ {known ? setSelectedId(row.id)}
+ onCopy={handleCopy}
+ /> : }
+
+
+
+ {/* ── detail panel ── */}
+ {selected ? (
+
{selected.value}}
+ description={selected.name}
+ headerRight={
+
+ }
+ contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
+ >
+
+
+ {selected.enabled ? "Активен" : "Выключен"}
+
+
+ {selected.description}
+
+
+ {[
+ ["Тип", TYPE_LABELS[selected.type]],
+ ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
+ ["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
+ ["Серверов", selected.serverCount.toLocaleString("ru-RU")],
+ ].map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+ {selected.filterIds.length > 0 && (
+
+
+ Использующие фильтры
+
+
+ {selected.filterIds.map(fid => (
+
+ {filterName(fid)}
+
+ ))}
+
+
+ )}
+
+
+ { void handleCopy(selected.value) }}>
+ {copied === selected.value ? : }
+ Скопировать значение
+
+
+
+ ) : (
+
+
+
+ Выберите community для просмотра деталей
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx
new file mode 100644
index 00000000..4f5329cd
--- /dev/null
+++ b/app/(main)/containers/page.tsx
@@ -0,0 +1,641 @@
+"use client"
+
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
+import { PageHeader } from "@/components/page-header"
+import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
+import type { RouterContainer, Server } from "@/lib/data"
+import { Flag } from "@/components/flag"
+import { Frame, FramePanel } from "@/components/reui/frame"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { Button } from "@/components/ui/button"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import {
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
+ DropdownMenuItem, DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+import {
+ BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
+ MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
+ CodeXmlIcon, ActivityIcon, ServerIcon,
+ TerminalIcon, AlertCircleIcon, RefreshCwIcon,
+} from "lucide-react"
+import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
+import { useDataSource } from "@/lib/data-source"
+import { ApiClientError, requestJson } from "@/shared/api/http-client"
+import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
+import { serverListSchema } from "@mmapp/contracts/servers"
+import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
+import { can } from "@/lib/auth"
+import { userApiError } from "@/shared/api/users"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
+
+type ContainerView = Omit & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
+type ContainerServer = Omit & {status: Server["status"] | null}
+
+interface BackendServer {
+ id: number
+ name: string
+ host: string
+ type?: Server["type"]
+ site?: string
+ country: string
+ asn?: string
+ enabled: boolean
+ status?: Server["status"] | null
+ latency?: number | null
+}
+
+function mapBackendServer(s: BackendServer): ContainerServer {
+ return {
+ id: String(s.id),
+ name: s.name || s.host,
+ host: s.host,
+ model: "—",
+ os: "—",
+ site: s.site ?? "",
+ country: s.country || "UN",
+ asn: s.asn ?? "",
+ type: s.type ?? "exit-node",
+ enabled: s.enabled,
+ status: s.status ?? null,
+ latency: s.latency ?? null,
+ sessions: 0,
+ }
+}
+
+function statusConfig(status: ContainerView["status"]) {
+ return {
+ unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
+ running: {
+ dot: "bg-success",
+ badge: "bg-success/10 text-success border-success/20",
+ label: "Работает",
+ },
+ stopped: {
+ dot: "bg-muted-foreground",
+ badge: "bg-muted/50 text-muted-foreground border-border",
+ label: "Остановлен",
+ },
+ error: {
+ dot: "bg-destructive",
+ badge: "bg-destructive/10 text-destructive border-destructive/20",
+ label: "Ошибка",
+ },
+ }[status]
+}
+
+type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
+function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
+ const formats = useMemo(() => [{
+ id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
+ code: selection ? JSON.stringify(selection, null, 2) : "",
+ }], [selection])
+ return {selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}}
+ returnFocus={() => Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
+ formats={formats} />
+}
+
+function ContainerCard({
+ container,
+ server,
+ live,
+ busy,
+ onExport,
+ onStart,
+ onStop,
+ onRestart,
+ onRemove,
+}: {
+ container: ContainerView
+ server?: ContainerServer
+ live: boolean
+ busy: boolean
+ onExport: () => void
+ onStart: () => void
+ onStop: () => void
+ onRestart: () => void
+ onRemove: () => void
+}) {
+ const cfg = statusConfig(container.status)
+ const canMutate = live && Boolean(container.rosId) && !busy
+
+ return (
+
+
+
+
+
+ {container.name}
+
+ {cfg.label}
+
+
+
+
+
+
+ } />
+
+ {container.status === "running" ? (
+
+ Остановить
+
+ ) : (
+
+ Запустить
+
+ )}
+ Логи
+ Редактировать
+ Снимок JSON
+
+
+ Перезапустить
+
+
+
+ Удалить
+
+
+
+
+
+
+
+
+
+ {container.image || "Образ неизвестен"}{container.tag && <>:{container.tag} >}
+
+
+
+ {server && (
+
+
+
+ {server.name}
+
+ )}
+
+ {container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
+
+ {container.uptime && (
+
+ )}
+ {container.cpu !== undefined && (
+
+ CPU
+ 50 ? "text-warning" : "text-foreground")}>
+ {container.cpu}%
+
+
+ )}
+ {container.memMb !== undefined && (
+
+ RAM
+ {container.memMb} МиБ
+
+ )}
+
+ )}
+
+ {container.interfaces.length > 0 && (
+
+ {container.interfaces.map((i) => (
+
+ {i}
+
+ ))}
+
+ )}
+
+ {container.mounts.length > 0 && (
+
+ {container.mounts.map((m, idx) => (
+
+ →
+ {m.dst}
+ {m.src && <>← {m.src} >}
+
+ ))}
+
+ )}
+
+ {container.comment && (
+
{container.comment}
+ )}
+
+
+
+ )
+}
+
+export default function ContainersPage() { return Загрузка контекста контейнеров…}> }
+function ContainersSource() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ if (!prefsHydrated) return Загрузка настроек контейнеров…
+ return
+}
+function ContainersContent() {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+ const canRead = can("mm:dashboard:read")
+ const canReadServers = can("mm:servers:read")
+ const canWrite = can("mm:servers:write")
+ const params = useSearchParams()
+ const context = readContainersUrl(params)
+ const search = context.search
+ const statusFilter = context.status
+ const selectedServerId = context.server
+ const urlError = context.error
+ function writeContext(changes: Record, replace = false) {
+ const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
+ if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
+ }
+ const setSearch = (value: string) => writeContext({ q: value }, true)
+ const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
+ const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
+ const [exportContainer, setExportContainer] = useState(null)
+ const [state, setState] = useState(null)
+ const [lastStates, setLastStates] = useState>({})
+ const [liveServers, setLiveServers] = useState([])
+ const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
+ const [busyId, setBusyId] = useState(null)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+ const deleteReturnLabel = useRef(null)
+ useEffect(() => {
+ if (deleteTarget || busyId || !deleteReturnLabel.current) return
+ const frame = requestAnimationFrame(() => {
+ const target = Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
+ ;(target ?? Array.from(document.querySelectorAll("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
+ })
+ return () => cancelAnimationFrame(frame)
+ }, [deleteTarget, busyId])
+ const [operationError, setOperationError] = useState(null)
+ const [operationNotice, setOperationNotice] = useState(null)
+ const [commandNeedsRead, setCommandNeedsRead] = useState(false)
+ const mutationLock = useRef(false)
+ const mutationController = useRef(null)
+ const alive = useRef(true)
+ useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
+ const [liveError, setLiveError] = useState(null)
+ const [serversError, setServersError] = useState(null)
+ const [readAt, setReadAt] = useState(null)
+ const [serversReadAt, setServersReadAt] = useState(null)
+ const loadController = useRef(null)
+ const loadRevision = useRef(0)
+ const loadLive = useCallback(async () => {
+ if (!isLive) return false
+ loadController.current?.abort()
+ const controller = new AbortController()
+ loadController.current = controller
+ const revision = ++loadRevision.current
+ const current = () => !controller.signal.aborted && loadRevision.current === revision
+ setLoading(true)
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
+ let stateComplete = !canRead
+ const tasks = await Promise.allSettled([
+ canRead ? readContainersState(backendUrl, { signal }).then(result => {
+ stateComplete = result.servers.every(node => node.availability !== "error")
+ if (current()) {
+ setState(result); setReadAt(Date.now()); setLiveError(null)
+ if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
+ setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
+ }
+ }).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
+ canReadServers ? requestJson(backendUrl, "/api/servers", { signal }).then(payload => {
+ const result = serverListSchema.parse(payload)
+ if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
+ if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
+ }).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
+ ])
+ if (current()) setLoading(false)
+ return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
+ }, [isLive, backendUrl, canRead, canReadServers])
+ useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
+ const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
+ const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
+ const effectiveServerId = selectedServerId
+ const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
+ const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
+ const stale = isLive && !!liveError
+ const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
+ const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
+ const scoped = useMemo(() => {
+ if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
+ return displayContainers.filter((c) => c.serverId === effectiveServerId)
+ }, [displayContainers, effectiveServerId])
+
+ const serverById = useMemo(
+ () => Object.fromEntries(displayServers.map((s) => [s.id, s])),
+ [displayServers],
+ )
+
+ const railItems = useMemo(() => (
+ displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status ?? "unknown",
+ type: s.type,
+ enabled: s.enabled,
+ meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
+ }))
+ ), [displayServers, displayContainers, state, isLive])
+
+ const filtered = useMemo(() => {
+ return scoped.filter((c) => {
+ if (statusFilter !== "all" && c.status !== statusFilter) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return (
+ c.name.toLowerCase().includes(q) ||
+ c.image.toLowerCase().includes(q) ||
+ (serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
+ )
+ })
+ }, [search, statusFilter, scoped, serverById])
+
+ const running = scoped.filter((c) => c.status === "running").length
+ const stopped = scoped.filter((c) => c.status === "stopped").length
+ const errors = scoped.filter((c) => c.status === "error").length
+ const statusKnown = known && scoped.every(container => container.status !== "unknown")
+ const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
+ const deleteStopped = currentDeleteTarget?.status === "stopped"
+
+ async function mutate(c: ContainerView, action: ContainerAction) {
+ if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
+ if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
+ mutationLock.current = true
+ const controller = new AbortController()
+ mutationController.current = controller
+ const current = () => alive.current && !controller.signal.aborted
+ setBusyId(c.id); setOperationError(null); setOperationNotice(null)
+ try {
+ await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
+ if (!current()) return
+ const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
+ const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
+ setOperationNotice(message)
+ if (action === "remove") {
+ setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
+ setDeleteTarget(null)
+ }
+ toast.success("Команда подтверждена API.")
+ const refreshed = await loadLive()
+ if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
+ } catch (error) {
+ if (!current()) return
+ const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
+ setCommandNeedsRead(uncertain)
+ setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
+ } finally {
+ if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
+ if (alive.current) setBusyId(null)
+ }
+ }
+
+ return (
+ <>
+
+
+ { void loadLive() }}
+ disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
+ >
+
+ Обновить
+
+
+ Новый контейнер
+
+ >
+ }
+ />
+ }
+ >
+
+
+ {urlError &&
{urlError} writeContext({server:null,status:null,q:null})}>Сбросить контекст }
+ {operationNotice &&
{operationNotice}
}
+ {operationError && !deleteTarget &&
{operationError} }
+ {isLive && !canRead &&
Для каталога контейнеров требуется mm:dashboard:read.
}
+ {isLive && !canReadServers &&
Для каталога серверов требуется mm:servers:read. Команды недоступны.
}
+ {isLive && loading && !state &&
Загрузка контейнеров…
}
+ {isLive && readAt &&
Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}
}
+ {isLive && serversReadAt &&
Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}
}
+ {isLive && liveError &&
{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError} }
+ {isLive && serversError &&
{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError} }
+ {isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ?
Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."} : node.availability === "unsupported" ?
Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.
: null)}
+ {isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) &&
Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.
}
+ {mode === "mock" && (
+
+ Моковые данные
+
+ )}
+
+
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "running",
+ label: "Работает",
+ value: statusKnown ? running : "—",
+ icon:
,
+ iconClassName: "text-success",
+ },
+ {
+ id: "stopped",
+ label: "Остановлен",
+ value: statusKnown ? stopped : "—",
+ icon:
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "errors",
+ label: "Ошибок",
+ value: statusKnown ? errors : "—",
+ icon:
,
+ iconClassName: "text-destructive",
+ variant: errors > 0 ? "destructive" : "default",
+ },
+ ]}
+ />
+
+
+
+
+
Контейнеры доступны с RouterOS 7.4+
+
+ Поддерживаются Docker-совместимые образы. Требуется установка пакета container.
+ Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
+
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
+ setStatusFilter(s)}
+ className={cn(
+ "px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
+ statusFilter === s
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}>
+ {s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
+
+ ))}
+
+
+
{known ? filtered.length : "—"} контейнеров
+
+
+ {filtered.length === 0 ? (!known ?
Список контейнеров пока неизвестен.
: (
+
+
+
Контейнеры не найдены
+
{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
+
+ )) : (
+
+ {filtered.map((c) => (
+ { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
+ onStart={() => { void mutate(c, "start") }}
+ onStop={() => { void mutate(c, "stop") }}
+ onRestart={() => { void mutate(c, "restart") }}
+ onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
+ />
+ ))}
+
+ )}
+
+
+
+ {[
+ {
+ title: "Установка пакета",
+ lines: [
+ "# Скачать пакет container:",
+ "# mikrotik.com → Software",
+ "",
+ "/system/package/install",
+ " container",
+ "",
+ "# Перезагрузить:",
+ "/system/reboot",
+ ],
+ },
+ {
+ title: "Создать контейнер",
+ lines: [
+ "# veth интерфейс:",
+ "/interface/veth/add \\",
+ " name=veth-nginx \\",
+ " address=172.17.0.2/24 \\",
+ " gateway=172.17.0.1",
+ "",
+ "# Контейнер:",
+ "/container/add \\",
+ " remote-image=nginx:alpine \\",
+ " interface=veth-nginx",
+ ],
+ },
+ {
+ title: "Управление",
+ lines: [
+ "# Список:",
+ "/container/print",
+ "",
+ "# Запуск:",
+ "/container/start 0",
+ "",
+ "# Остановка:",
+ "/container/stop 0",
+ "",
+ "# Логи:",
+ "/container/shell 0",
+ ],
+ },
+ ].map((b) => (
+
+
{b.title}
+
+ {b.lines.join("\n")}
+
+
+ ))}
+
+
+
+
+
+
+ {deleteTarget && { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
+
+ Удалить контейнер? {deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}
+ {operationError && {operationError}
}
+
+ {(commandNeedsRead || !deleteStopped) && { void loadLive() }}>Обновить каталог }
+ Отмена
+ { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}
+
+
+ }
+ setExportContainer(null)} />
+ >
+ )
+}
diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx
new file mode 100644
index 00000000..f5750db5
--- /dev/null
+++ b/app/(main)/dashboard/page.tsx
@@ -0,0 +1,351 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import Link from "next/link"
+import {
+ ActivityIcon,
+ BellIcon,
+ CableIcon,
+ FilterIcon,
+ GitMergeIcon,
+ HardDriveIcon,
+ HeartPulseIcon,
+ MapIcon,
+ ServerIcon,
+ ShieldIcon,
+} from "lucide-react"
+import { PageHeader } from "@/components/page-header"
+import { OpsPanel } from "@/components/ops-panel"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { QuickActionGrid } from "@/components/reui-kit/quick-action-grid"
+import { AttentionQueue } from "@/components/reui-kit/attention-queue"
+import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
+import { LatencyChart } from "@/components/dashboard/latency-chart"
+import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events-timeline"
+import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
+import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
+import { EmptyState } from "@/components/empty-state"
+import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
+import { Badge } from "@/components/reui/badge"
+import { buttonVariants } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+import { fmtRate } from "@/lib/fmt-rate"
+import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
+
+function fmtIntRu(n: number): string {
+ return n.toLocaleString("ru-RU")
+}
+
+function AttentionRows({ rows }: { rows: AttentionRow[] }) {
+ const [expanded,setExpanded]=useState(false)
+ const [visible,setVisible]=useState(55)
+ function renderRow(row: AttentionRow) {
+ return (
+
+ {row.title}
+
+ {row.hint}
+
+
+ )
+ }
+
+ return (
+
+ {rows.slice(0, 5).map(renderRow)}
+ {rows.length > 5 && (
+ {setExpanded(event.currentTarget.open);if(!event.currentTarget.open)setVisible(55)}}>
+ Ещё {rows.length - 5}
+ {expanded && rows.slice(5,visible).map(renderRow)}
+ {expanded && rows.length>visible && setVisible(count=>Math.min(count+50,rows.length))}>Показать ещё {Math.min(50,rows.length-visible)} }
+
+ )}
+
+ )
+}
+
+export default function DashboardPage() {
+ const dash = useDashboardLive()
+ const offline = dash.offlineCount
+ const health = dash.probeDown + dash.probeWarn
+
+ return (
+
+
+
+
+
+ {dash.isLive &&
}
+
+
0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
+ ? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
+ : dash.totalServers > 0
+ ? "все в сети"
+ : "нет серверов",
+ href: "/servers",
+ icon: ,
+ iconClassName: "text-muted-foreground",
+ variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
+ },
+ {
+ id: "overlay",
+ label: "Туннели работают",
+ value: dash.availability.overlay ? String(dash.overlay.up) : "—",
+ hint: !dash.availability.overlay ? "не все источники доступны" : !dash.reliable.overlay ? "Последний успешный снимок; не все источники обновлены" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей",
+ href: "/gre",
+ icon: ,
+ iconClassName: "text-info",
+ variant: dash.overlay.down > 0 ? "warning" : "default",
+ },
+ {
+ id: "bgp",
+ label: "Префиксы BGP",
+ value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—",
+ hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных",
+ href: "/bgp",
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ {
+ id: "health",
+ label: "Проверки с проблемами",
+ value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
+ hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
+ href: "/uptime",
+ icon: ,
+ iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
+ variant: health > 0 ? "destructive" : "default",
+ },
+ ]}
+ />
+
+
+ Требует внимания
+ 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено",
+ emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.",
+ children: ,
+ },
+ {
+ id: "overlay",
+ title: "Туннели",
+ icon: CableIcon,
+ iconClassName: "text-info",
+ count: dash.attentionOverlay.length,
+ countVariant: "destructive-light",
+ emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено",
+ emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.",
+ children: ,
+ },
+ {
+ id: "probes",
+ title: "Проверки",
+ icon: HeartPulseIcon,
+ iconClassName: "text-destructive",
+ count: dash.attentionProbes.length,
+ countVariant: "destructive-light",
+ emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено",
+ emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.",
+ children: ,
+ },
+ ]}
+ />
+
+
+
+
+ {dash.latency.kind === "loading" && (
+
+ )}
+ {dash.latency.kind === "empty" && (
+ {dash.latency.message}
+ )}
+ {(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
+
+ )}
+
+
+
+ Все →
+
+ }
+ >
+
+
+
+
+
+
Трафик →}
+ contentClassName="px-3 pb-3"
+ >
+ {dash.loading && !dash.traffic ? (
+
+ ) : !dash.traffic ? (
+
+ Нет данных трафика за последний час.{" "}
+
+ Открыть трафик
+
+
+ ) : (
+ <>
+
+ {dash.traffic.demo ? демо : !dash.traffic.observationsKnown ? Измерения не подтверждены API : null}
+ RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}
+ TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {dash.probesSubtitle}
+ }
+ headerRight={
+
+ Мониторинг →
+
+ }
+ contentClassName="px-0 pb-0"
+ >
+ {dash.activeProbes.length === 0 && !dash.probesLoading ? (
+
+ Открыть мониторинг
+
+ }
+ />
+ ) : (
+
+ )}
+
+
+ ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "map",
+ title: "Карта сети",
+ description: "Топология GRE и WAN",
+ href: "/network-map",
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ {
+ id: "filters",
+ title: "Фильтры",
+ description: "Правила обхода и списки",
+ href: "/filters",
+ icon: ,
+ iconClassName: "text-info",
+ },
+ {
+ id: "firewall",
+ title: "Firewall",
+ description: "Цепочки filter / nat",
+ href: "/firewall",
+ icon: ,
+ iconClassName: "text-warning",
+ },
+ {
+ id: "backups",
+ title: "Бэкапы",
+ description: "Снапшоты и расписание",
+ href: "/backups",
+ icon: ,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "bgp",
+ title: "BGP",
+ description: "Сессии и префиксы",
+ href: "/bgp",
+ icon: ,
+ iconClassName: "text-primary",
+ },
+ ]}
+ />
+
+
+
+
+ )
+}
diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx
new file mode 100644
index 00000000..6dd73209
--- /dev/null
+++ b/app/(main)/data-collection/page.tsx
@@ -0,0 +1,1217 @@
+"use client"
+
+import Link from "next/link"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { PageHeader } from "@/components/page-header"
+import { Badge } from "@/components/reui/badge"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import { useDataSource } from "@/lib/data-source"
+import {
+ SCHEDULER_JOB_DESCRIPTIONS,
+ SCHEDULER_JOB_KEYS,
+ SCHEDULER_JOB_LABELS,
+ type SchedulerJobStatusDto,
+ type SchedulerStatusDto,
+ type SchedulerRunRowDto,
+} from "@/lib/scheduler-settings"
+import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
+import { can } from "@/lib/auth"
+import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
+import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
+import { ApiClientError } from "@/shared/api/http-client"
+import {
+ parseSchedulerRunSnapshot,
+ type AlertEngineRunSnapshot,
+ type GreBgpSnapshotRunSnapshot,
+ type InternetPathRunSnapshot,
+ type CertificatesRenewRunSnapshot,
+ type BackupsRunSnapshot,
+ type GeoipUpdateRunSnapshot,
+ type PingRunSnapshot,
+ type ResourcesRunSnapshot,
+ type SchedulerRunSnapshot,
+ type ServersRestPingRunSnapshot,
+ type SpeedScheduledRunSnapshot,
+ type TrafficRunSnapshot,
+} from "@/lib/scheduler-run-snapshot"
+import {
+ AlertEngineRuleDiagGrid,
+ PingSnapshotGrid,
+ ResourcesSnapshotGrid,
+ ServersRestPingSnapshotGrid,
+ SpeedSnapshotGrid,
+ TrafficSnapshotGrid,
+} from "@/components/data-grids/snapshot-data-grid"
+import {
+ DataCollectionSchedulerDataGrid,
+ type SchedulerJobGridRow,
+} from "@/components/data-grids/data-collection-scheduler-data-grid"
+import { DataPageCard } from "@/components/data-page-card"
+import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
+import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
+import { cn } from "@/lib/utils"
+import {
+ AlertCircleIcon,
+ CalendarClockIcon,
+ CheckCircleIcon,
+ ChevronDownIcon,
+ DatabaseIcon,
+ LoaderCircleIcon,
+ RefreshCwIcon,
+ XCircleIcon,
+} from "lucide-react"
+
+function extractApiError(reason: unknown): string {
+ if (reason instanceof ApiClientError) return reason.message
+ if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
+ if (reason instanceof Error) return reason.message
+ return String(reason)
+}
+
+function fmtMs(ms: number): string {
+ if (ms < 1000) return `${ms} мс`
+ const s = ms / 1000
+ return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
+}
+
+function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
+ if (snap.job === "router_logs") return {snap.skipped &&
Сбор логов выключен или приостановлен.
}{snap.servers.map(server =>
{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}
)}
+ if (snap.job === "traffic") {
+ const t = snap as TrafficRunSnapshot
+ return (
+
+ {t.skipped ? (
+
Прогон пропущен: предыдущий сбор трафика ещё выполнялся.
+ ) : null}
+ {t.fatalError ? (
+
+
+ Критическая ошибка: {t.fatalError}
+
+ ) : null}
+
+ Сэмплы на момент {new Date(t.sampledAt).toLocaleString("ru-RU")}
+
+
+
+
+
+ )
+ }
+ if (snap.job === "uptime_resources") {
+ const u = snap as ResourcesRunSnapshot
+ return (
+
+ {u.skipped ? (
+
Прогон пропущен: сбор ресурсов уже выполняется.
+ ) : null}
+ {u.fatalError ? (
+
+
+ Критическая ошибка: {u.fatalError}
+
+ ) : null}
+
+ Сэмплы на {new Date(u.sampledAt).toLocaleString("ru-RU")}
+
+
+
+
+
+ )
+ }
+ if (snap.job === "servers_rest_ping") {
+ const s = snap as ServersRestPingRunSnapshot
+ return (
+
+ {s.skipped ? (
+
Прогон пропущен: предыдущая проверка API ещё выполнялась.
+ ) : null}
+ {s.fatalError ? (
+
+
+ Критическая ошибка: {s.fatalError}
+
+ ) : null}
+
+ GET /system/identity на{" "}
+ {new Date(s.sampledAt).toLocaleString("ru-RU")}
+
+
+
+
+
+ )
+ }
+ if (snap.job === "uptime_ping") {
+ const p = snap as PingRunSnapshot
+ return (
+
+ {p.skipped ? (
+
Прогон пропущен: сбор ping уже выполняется.
+ ) : null}
+ {p.fatalError ? (
+
+
+ Критическая ошибка: {p.fatalError}
+
+ ) : null}
+ {p.skippedByInterval != null && p.skippedByInterval > 0 ? (
+
Пропущено по интервалу проб: {p.skippedByInterval}
+ ) : null}
+
+ Сэмплы на {new Date(p.sampledAt).toLocaleString("ru-RU")} — только пробы, для которых записан замер в этом тике
+
+
+
+
+
+ )
+ }
+ if (snap.job === "uptime_speed") {
+ const s = snap as SpeedScheduledRunSnapshot
+ return (
+
+
+ Прогоны speed на {new Date(s.sampledAt).toLocaleString("ru-RU")} — по очереди для каждой включённой пробы
+
+
+
+
+
+ )
+ }
+ if (snap.job === "gre_bgp") {
+ const g = snap as GreBgpSnapshotRunSnapshot
+ return (
+
+ {g.skipped ? (
+
Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.
+ ) : null}
+ {g.fatalError ? (
+
+
+ Критическая ошибка: {g.fatalError}
+
+ ) : null}
+
+ Запись в PostgreSQL на{" "}
+ {new Date(g.sampledAt).toLocaleString("ru-RU")}
+ {" "}— строки GRE и BGP для движка оповещений.
+
+
+
+
Строк GRE
+ {g.greWritten}
+
+
+
Строк BGP
+ {g.bgpWritten}
+
+
+ {g.errors?.length ? (
+
+ {g.errors.map((e, i) => (
+
+ {e}
+
+ ))}
+
+ ) : null}
+
+ )
+ }
+ if (snap.job === "internet_path") {
+ const p = snap as InternetPathRunSnapshot
+ return (
+
+
+ Снимок internet-path на{" "}
+ {new Date(p.sampledAt).toLocaleString("ru-RU")}
+
+
+
+
Home routers
+ {p.homes}
+
+
+
Сохранение snapshot
+ {p.snapshotSaved ? "ok" : "no"}
+
+
+ {p.fatalError ? (
+
+
+ Критическая ошибка: {p.fatalError}
+
+ ) : null}
+
+ )
+ }
+ if (snap.job === "certificates_renew") {
+ const c = snap as CertificatesRenewRunSnapshot
+ return (
+
+ {c.skipped ? (
+
Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.
+ ) : null}
+
+
+
Проверено
+ {c.checked}
+
+
+
Обновлено
+ {c.renewed}
+
+
+
Пропущено
+ {c.skippedTargets}
+
+
+
Ошибки
+ {c.errors.length}
+
+
+ {c.errors.length ? (
+
+ {c.errors.map((e, i) => (
+
{e}
+ ))}
+
+ ) : null}
+
+ )
+ }
+ if (snap.job === "backups") {
+ const b = snap as BackupsRunSnapshot
+ return (
+
+
+
+
Слот расписания
+ {b.due ? "да" : "нет"}
+
+
+
Создано
+ {b.created}
+
+
+
Ошибки
+ {b.failures}
+
+
+
Удалено старых
+ {b.pruned}
+
+
+ {b.errors?.length ? (
+
+ {b.errors.map((e, i) => (
+
{e}
+ ))}
+
+ ) : null}
+
+ )
+ }
+ if (snap.job === "geoip_update") {
+ const g = snap as GeoipUpdateRunSnapshot
+ return (
+
+ {g.skipped ? (
+
Прогон пропущен: обновление уже выполнялось или задача отключена.
+ ) : null}
+
+
+
Баз проверено
+ {g.checked}
+
+
+
Скачано
+ {g.downloaded}
+
+
+
Без изменений
+ {g.skippedUnchanged}
+
+
+
Объём, МБ
+ {(g.bytes / 1024 / 1024).toFixed(1)}
+
+
+ {g.errors.length ? (
+
+ {g.errors.map((e, i) => (
+
{e}
+ ))}
+
+ ) : null}
+
+ )
+ }
+ if (snap.job === "alert_engine") {
+ const a = snap as AlertEngineRunSnapshot
+ return (
+
+
+ Снимок на{" "}
+ {new Date(a.sampledAt).toLocaleString("ru-RU")}
+ . Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
+
+
+
+
Правил проверено
+ {a.rulesChecked}
+
+
+
Отправок (правила)
+ {a.standaloneFires}
+
+
+
Отправок (группы)
+ {a.groupFires}
+
+
+
Без Telegram
+ {a.skippedNoTelegram ? "да" : "нет"}
+
+
+ {a.errors?.length ? (
+
+
+
+ {a.errors.map((e, i) => (
+
+ {e}
+
+ ))}
+
+
+ ) : null}
+ {a.ruleDiag && a.ruleDiag.length > 0 ? (
+
+
По правилам (почему не ушло в Telegram)
+
+
+
+
+ ) : null}
+
+ )
+ }
+ return null
+}
+
+function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
+ const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
+ const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
+ const snapshot = useMemo(() => {
+ const parsed = parseSchedulerRunSnapshot(r.resultJson)
+ return parsed?.job === r.jobKey ? parsed : null
+ }, [r.resultJson, r.jobKey])
+ const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "")
+ return (
+
+
+
+
ID записи
+ {r.id}
+
+
+
Ключ задачи
+ {r.jobKey}
+
+
+
Название и назначение
+
+ {jobTitle}
+ {jobDesc}
+
+
+
+
Старт
+ {new Date(r.startedAt).toLocaleString("ru-RU")}
+
+
+
Завершение
+ {new Date(r.finishedAt).toLocaleString("ru-RU")}
+
+
+
Длительность
+
+ {r.durationMs} мс
+ ({fmtMs(r.durationMs)})
+
+
+
+
Результат
+
+
+ {r.status}
+
+
+
+
+ {r.error ? (
+
+
Текст ошибки
+
+ {r.error}
+
+
+ ) : (
+
Ошибка не записывалась (статус ok).
+ )}
+
+
+
+
+
Результаты измерений
+ {snapshot ? (
+
+ ) : hasSnapshot ? (
+
+
+ Снимок недоступен
+ Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.
+
+ ) : (
+
+ Снимок отсутствует (запись до обновления бекенда или задача без детализации).
+
+ )}
+
+
+ )
+}
+
+const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
+const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const
+type SettingsKey = keyof typeof SETTINGS_LABELS
+type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
+const INITIAL_SETTINGS: Record = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record
+
+export default function DataCollectionPage() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ if (!prefsHydrated) return Загрузка настроек источника…
+ return
+}
+
+function DataCollectionContent() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ const isLive = prefsHydrated && mode === "live"
+
+ const [trafficCollector, setTrafficCollector] = useState> | null>(null)
+ const [serversApiCollector, setServersApiCollector] = useState> | null>(null)
+ const [uptimeCollector, setUptimeCollector] = useState> | null>(null)
+ const [internetPathCollector, setInternetPathCollector] = useState> | null>(null)
+ const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
+ const dirtySettings = useRef(new Set())
+ const dirtyFields = useRef>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
+ const [dirtyCount, setDirtyCount] = useState(0)
+ const [saveMessage, setSaveMessage] = useState(null)
+ const mutationLock = useRef(false)
+ const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error
+ const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) }
+ const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
+ const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
+ const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
+ const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
+ const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
+ const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
+ const [draftTrafficEnabled, setDraftTrafficEnabled] = useState(true)
+ const [draftServersApiEnabled, setDraftServersApiEnabled] = useState(false)
+ const [serversApiIntervalDraft, setServersApiIntervalDraft] = useState("120")
+ const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true)
+ const [draftPingEnabled, setDraftPingEnabled] = useState(true)
+ const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
+ const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
+ const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
+ const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
+ const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
+ const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
+ const [schedulerRuns, setSchedulerRuns] = useState([])
+ const [schedulerStatus, setSchedulerStatus] = useState(null)
+ const [schedulerStatusError, setSchedulerStatusError] = useState(null)
+ const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState(null)
+ const [runsKnown, setRunsKnown] = useState(false)
+ const [runsError, setRunsError] = useState(null)
+ const [runsReceivedAt, setRunsReceivedAt] = useState(null)
+ const [runFilterJobKey, setRunFilterJobKey] = useState("")
+ const [runNowJobKey, setRunNowJobKey] = useState(null)
+ const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
+ const [pageBusy, setPageBusy] = useState(false)
+ const [collectorError, setCollectorError] = useState(null)
+ const [openRunIds, setOpenRunIds] = useState>(() => new Set())
+
+ const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
+ useEffect(() => {
+ reader.current.active = true
+ const state = reader.current
+ return () => { state.active = false; state.revision++; state.controller?.abort() }
+ }, [])
+
+ const toggleRunOpen = useCallback((id: string, open: boolean) => {
+ setOpenRunIds((prev) => {
+ const next = new Set(prev)
+ if (open) next.add(id)
+ else next.delete(id)
+ return next
+ })
+ }, [setOpenRunIds])
+
+ const schedulerJobsByKey = useMemo(() => {
+ const jobs = schedulerStatus?.jobs ?? []
+ return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record
+ }, [schedulerStatus?.jobs])
+
+ const loadCollectors = useCallback(async () => {
+ if (!isLive || !reader.current.active || mutationLock.current) return
+ const revision = ++reader.current.revision
+ reader.current.controller?.abort()
+ const controller = new AbortController(); reader.current.controller = controller
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
+ const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
+ setPageBusy(true)
+ try {
+ const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined
+ const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => {
+ if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) }
+ return data
+ }, error => { if (current()) setRunsError(extractApiError(error)); throw error })
+ const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => {
+ if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
+ return data
+ }, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
+ function publish(key: SettingsKey, request: Promise, accept: (data: T, updateDraft: boolean) => void) {
+ return request.then(data => {
+ if (current()) {
+ accept(data, !dirtySettings.current.has(key))
+ setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
+ }
+ }, error => {
+ if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
+ })
+ }
+ await Promise.allSettled([
+ publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
+ setTrafficCollector(data)
+ if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
+ }),
+ publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
+ setServersApiCollector(data)
+ if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
+ }),
+ publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
+ setUptimeCollector(data)
+ if (draft) {
+ setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
+ setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
+ }
+ }),
+ publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
+ setInternetPathCollector(data)
+ if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
+ }),
+ publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
+ if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
+ }),
+ runsRequest, statusRequest,
+ ])
+
+ } catch (e) {
+ if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
+ } finally {
+ if (current()) setPageBusy(false)
+ }
+ }, [backendUrl, isLive, runFilterJobKey])
+
+ const writeSettings = useCallback(async (key: SettingsKey, patch: Record) => {
+ if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
+ const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
+ : key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
+ : key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
+ : key === "internet" ? await putInternetPathSettings(backendUrl, patch)
+ : await putCertificateRenewSettings(backendUrl, patch)
+ if (Object.entries(patch).some(([field, value]) => (data as Record)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
+ if (!reader.current.active) return
+ if (key === "traffic") setTrafficCollector(data as Awaited>)
+ if (key === "rest") setServersApiCollector(data as Awaited>)
+ if (key === "uptime") setUptimeCollector(data as Awaited>)
+ if (key === "internet") setInternetPathCollector(data as Awaited>)
+ setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
+ }, [backendUrl, isLive, settingsReads])
+
+ const persistSchedulerDrafts = async () => {
+ if (mutationLock.current || !reader.current.active) return
+ const drafts: Record> = {
+ traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
+ uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
+ internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
+ }
+ const plan: { key: SettingsKey; patch: Record }[] = []
+ try {
+ for (const key of Object.keys(drafts) as SettingsKey[]) {
+ if (!dirtyFields.current[key].size) continue
+ if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
+ const patch: Record = {}
+ for (const field of dirtyFields.current[key]) {
+ const raw = drafts[key][field]; const value = Number(raw)
+ const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
+ patch[field] = value
+ }
+ plan.push({ key, patch })
+ }
+ if (!plan.length) return
+ mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
+ const successes: string[] = [], errors: string[] = []
+ for (const { key, patch } of plan) {
+ if (!reader.current.active) break
+ try {
+ await writeSettings(key, patch)
+ if (!reader.current.active) return
+ for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field)
+ if (!dirtyFields.current[key].size) dirtySettings.current.delete(key)
+ successes.push(SETTINGS_LABELS[key])
+ } catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) }
+ }
+ if (!reader.current.active) return
+ setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0))
+ if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", "))
+ if (errors.length) setCollectorError(errors.join("; "))
+ } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
+ finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
+ }
+
+ const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
+ const targets = {
+ traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
+ uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
+ internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
+ } as const
+ if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
+ const [key, field, accept] = targets[jobKey as keyof typeof targets]
+ mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
+ try {
+ await writeSettings(key, { [field]: nextEnabled })
+ if (!reader.current.active) return
+ accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
+ } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
+ finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
+ }, [writeSettings])
+
+ useEffect(() => {
+ if (!isLive) return
+ queueMicrotask(() => { void loadCollectors() })
+ }, [isLive, loadCollectors])
+
+ const handleRefreshScheduler = async () => {
+ if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
+ mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
+ try {
+ const data = await refreshScheduler(backendUrl)
+ if (!reader.current.active) return
+ setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
+ setSaveMessage("Перечитывание таймеров подтверждено сервером")
+ } catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
+ finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
+ }
+
+ const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
+ const job = schedulerJobsByKey[jobKey]
+ if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
+ mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
+ try {
+ const data = await runSchedulerJobNow(backendUrl, jobKey)
+ if (!reader.current.active) return
+ if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
+ else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
+ const skipped = "skipped" in data.result.result && data.result.result.skipped
+ const notDue = data.result.result.job === "backups" && !data.result.result.due
+ setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
+ const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
+ if (warnings.length) setCollectorError(warnings.join("; "))
+ } catch (error) {
+ if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
+ } finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
+ }
+
+ const schedulerGridRows: SchedulerJobGridRow[] = (() => {
+ return SCHEDULER_JOB_KEYS.map((jobKey) => {
+ const j = schedulerJobsByKey[jobKey]
+ const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
+ const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
+ const enabled = fixedSchedule
+ ? Boolean(j?.enabled ?? true)
+ : jobKey === "traffic"
+ ? draftTrafficEnabled
+ : jobKey === "servers_rest_ping"
+ ? draftServersApiEnabled
+ : jobKey === "uptime_resources"
+ ? draftResourcesEnabled
+ : jobKey === "uptime_ping"
+ ? draftPingEnabled
+ : jobKey === "uptime_speed"
+ ? draftSpeedEnabled
+ : jobKey === "certificates_renew"
+ ? draftCertRenewEnabled
+ : draftInternetPathEnabled
+ const intervalValue = fixedSchedule
+ ? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
+ : jobKey === "traffic"
+ ? trafficIntervalDraft
+ : jobKey === "servers_rest_ping"
+ ? serversApiIntervalDraft
+ : jobKey === "uptime_resources"
+ ? uptimeResourceIntervalDraft
+ : jobKey === "uptime_ping"
+ ? uptimeIntervalDraft
+ : jobKey === "uptime_speed"
+ ? uptimeSpeedIntervalDraft
+ : jobKey === "certificates_renew"
+ ? certRenewIntervalDraft
+ : internetPathIntervalDraft
+ const onIntervalChange = fixedSchedule
+ ? () => {}
+ : jobKey === "traffic"
+ ? setTrafficIntervalDraft
+ : jobKey === "servers_rest_ping"
+ ? setServersApiIntervalDraft
+ : jobKey === "uptime_resources"
+ ? setUptimeResourceIntervalDraft
+ : jobKey === "uptime_ping"
+ ? setUptimeIntervalDraft
+ : jobKey === "uptime_speed"
+ ? setUptimeSpeedIntervalDraft
+ : jobKey === "certificates_renew"
+ ? setCertRenewIntervalDraft
+ : setInternetPathIntervalDraft
+ const defaultInterval = fixedSchedule
+ ? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
+ : jobKey === "traffic"
+ ? 30
+ : jobKey === "servers_rest_ping"
+ ? 120
+ : jobKey === "uptime_resources"
+ ? 300
+ : jobKey === "uptime_ping"
+ ? 15
+ : jobKey === "uptime_speed"
+ ? 60
+ : jobKey === "certificates_renew"
+ ? 21600
+ : 300
+
+ const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
+ return {
+ id: jobKey,
+ settingsKnown,
+ jobKey,
+ label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
+ description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
+ fixedSchedule,
+ enabled,
+ intervalValue: settingsKnown ? intervalValue : "",
+ intervalReadOnly: fixedSchedule,
+ intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
+ defaultInterval,
+ job: j,
+ onEnabledChange: fixedSchedule
+ ? undefined
+ : (nextEnabled) => {
+ void handleJobEnabledChange(jobKey, nextEnabled)
+ },
+ onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
+ onRunNow: async () => { await handleRunNow(jobKey) },
+ runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
+ runNowLoading: runNowJobKey === jobKey,
+ saveBusy: schedulerSaveBusy || !writable(settingsKey),
+ }
+ })
+ })()
+
+ const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
+ const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
+ const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0
+
+ const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
+
+ const stats = [
+ {
+ label: "Включено задач",
+ value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—",
+ sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса",
+ icon: ,
+ },
+ {
+ label: "Сейчас выполняется",
+ value: schedulerStatus ? String(runningJobsCount) : "—",
+ sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
+ icon: ,
+ },
+ {
+ label: "Трафик — последний сбор",
+ value: trafficCollector?.lastCollectedAt
+ ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
+ : "—",
+ sub: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
+ icon: trafficCollector?.lastError ? (
+
+ ) : trafficCollector?.lastCollectedAt ? (
+
+ ) : ( ),
+ },
+ {
+ label: "Журнал (в списке)",
+ value: runsKnown ? String(schedulerRuns.length) : "—",
+ sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
+ icon: ,
+ },
+ ]
+
+ return (
+
+
+
+ Настройки
+
+ { void loadCollectors() }}
+ >
+
+ Обновить
+
+ { void handleRefreshScheduler() }}>
+ Перечитать таймеры
+
+
+ }
+ />
+
+
+
+ {!prefsHydrated && (
+
+
+
+ )}
+
+ {prefsHydrated && !isLive && (
+
+
+ Открыть настройки
+
+
+ )}
+
+ {isLive && schedulerStatusError ? (
+
+
+ {schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}
+ {schedulerStatusError}
+
+ ) : null}
+ {isLive && schedulerStatusReceivedAt ?
Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}
: null}
+ {isLive && Object.entries(settingsReads).map(([key, value]) => (
+
+
{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}
+ {value.error ?
{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""} : null}
+
+ ))}
+ {isLive && saveMessage ?
{saveMessage}
: null}
+ {isLive && collectorError && (
+
+
+ Операция не завершена
+ {collectorError}
+
+ )}
+
+ {isLive && can("mm:settings:admin") &&
setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
+ {isLive && (
+ <>
+ ({
+ id: `dc-${i}`,
+ label: {s.label} ,
+ value: s.value,
+ hint: {s.sub} ,
+ icon: s.icon,
+ iconClassName:
+ s.label === "Трафик — последний сбор" && trafficCollector?.lastError
+ ? "text-destructive"
+ : s.label === "Сейчас выполняется" && runningJobsCount > 0
+ ? "text-warning"
+ : s.label === "Журнал (в списке)" && errorRunsInView
+ ? "text-destructive"
+ : s.label === "Трафик — последний сбор" && trafficCollector?.lastCollectedAt
+ ? "text-success"
+ : "text-muted-foreground",
+ variant:
+ s.label === "Трафик — последний сбор" && trafficCollector?.lastError
+ ? "destructive" as const
+ : s.label === "Журнал (в списке)" && errorRunsInView
+ ? "warning" as const
+ : "default" as const,
+ }))}
+ />
+
+
+
+
Планировщик сбора данных
+
+ Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
+
+
+
+
+
+
+
+
Хранение сэмплов трафика (дней)
+
{ markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
+ className="h-8 text-sm"
+ inputMode="numeric"
+ />
+
+
+
Хранение сэмплов uptime (дней)
+
{ markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
+ className="h-8 text-sm"
+ inputMode="numeric"
+ />
+
+
+
Обновлять сертификаты за (дней)
+
{ markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
+ className="h-8 text-sm"
+ inputMode="numeric"
+ disabled={!writable("cert") || schedulerSaveBusy}
+ />
+
+ Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
+
+
+
+
+ Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
+
+
{ void persistSchedulerDrafts() }}
+ >
+ {schedulerSaveBusy ? : null}
+ Сохранить изменения
+
+
+
+ Трафик — последний сбор:{" "}
+ {trafficCollector?.lastCollectedAt
+ ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU")
+ : "—"}{" "}
+ · {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}
+ {trafficCollector?.collectorRunning ? (
+
+ коллектор занят
+
+ ) : null}
+
+
+ {trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
+
+
+ REST API (серверы):{" "}
+ {serversApiCollector?.lastCollectedAt
+ ? new Date(serversApiCollector.lastCollectedAt).toLocaleString("ru-RU")
+ : "—"}{" "}
+ · {serversApiCollector?.lastDurationMs != null ? `${serversApiCollector.lastDurationMs} мс` : "—"}
+ {serversApiCollector?.collectorRunning ? (
+
+ проверка занята
+
+ ) : null}
+
+
+ {serversApiCollector?.lastError
+ ? `REST API (серверы): ${serversApiCollector.lastError}`
+ : (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
+
+
+ Uptime (агрегат в настройках):{" "}
+ {uptimeCollector?.lastCollectedAt
+ ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU")
+ : "—"}{" "}
+ · {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
+
+
+ {uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
+
+
+ Internet Path snapshot:{" "}
+ {internetPathCollector?.lastCollectedAt
+ ? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU")
+ : "—"}{" "}
+ · {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"}
+
+
+ {internetPathCollector?.lastError
+ ? `Internet Path: ${internetPathCollector.lastError}`
+ : (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
+
+
+
+
+
+ {isLive ? : null}
+
+
+
+ Задача
+
+ { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
+ >
+ Все
+ {SCHEDULER_JOB_KEYS.map((k) => (
+
+ {SCHEDULER_JOB_LABELS[k]}
+
+ ))}
+
+
+ }
+ contentClassName="px-0 pb-0"
+ >
+ {runsError ? (
+
+
+
+ {runsKnown ? "История не обновлена" : "История не получена"}
+ {runsError}
+
+
+ ) : null}
+ {runsReceivedAt ?
История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}
: null}
+ {!runsKnown ? (
+ !runsError ?
Загрузка журнала…
: null
+ ) : schedulerRuns.length === 0 ? (
+
Пока нет прогонов
+ ) : (
+
+ {schedulerRuns.map((r) => (
+
toggleRunOpen(r.id, open)}
+ >
+
+
+
+
+ {SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey}
+ {r.jobKey}
+
+
+ {new Date(r.finishedAt).toLocaleString("ru-RU")}
+
+
+ {r.durationMs} мс
+
+ {r.status}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/app/(main)/domains/page.tsx b/app/(main)/domains/page.tsx
new file mode 100644
index 00000000..e3b43493
--- /dev/null
+++ b/app/(main)/domains/page.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { PageHeader } from "@/components/page-header"
+import { DataPageCard } from "@/components/data-page-card"
+import { DataPageToolbar } from "@/components/data-page-toolbar"
+import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
+import { FileImportDialog } from "@/components/file-import-dialog"
+import { domains as mockDomains } from "@/lib/data"
+import { Button } from "@/components/ui/button"
+import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
+import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
+import { toast } from "sonner"
+
+export default function DomainsPage() {
+ const { mode, snapshot, loading, known } = useEvoCatalogView()
+ const [importOpen, setImportOpen] = useState(false)
+ const [search, setSearch] = useState("")
+
+ const useEvoCatalog = mode === "live"
+
+ const rows = useMemo(() => {
+ if (mode === "mock") return mockDomains
+ if (loading && !snapshot) return []
+ return snapshot?.domains ?? []
+ }, [mode, loading, snapshot])
+
+ const filtered = useMemo(() => {
+ if (!search) return rows
+ const q = search.toLowerCase()
+ return rows.filter(
+ (r) =>
+ r.domain.toLowerCase().includes(q) ||
+ r.asn.toLowerCase().includes(q) ||
+ r.filter.toLowerCase().includes(q),
+ )
+ }, [rows, search])
+
+ return (
+
+
+ setImportOpen(true)}>
+ Импорт
+
+ Экспорт
+ Добавить домен
+ >
+ }
+ />
+
+
+
+
Домены
+
+ Домены из каталога для BGP-фильтрации
+
+
+
+
+
+ {known || loading ? : }
+
+
+
+ {
+ toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
+ }}
+ />
+
+ )
+}
diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx
new file mode 100644
index 00000000..e3672849
--- /dev/null
+++ b/app/(main)/filters/page.tsx
@@ -0,0 +1,2129 @@
+"use client"
+
+import { useMemo, useState, useCallback, useEffect, useRef, useId } from "react"
+import { PageHeader } from "@/components/page-header"
+import { EmptyState } from "@/components/empty-state"
+import { StatusDot } from "@/components/status-dot"
+import { Flag } from "@/components/flag"
+import {
+ FiltersDataGrid,
+ type RecursiveRouteLite,
+} from "@/components/data-grids/filters-data-grid"
+import { DataPageCard } from "@/components/data-page-card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Combobox, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"
+import { Separator } from "@/components/ui/separator"
+import { cn } from "@/lib/utils"
+import { filtersResponseSchema, filtersMutationSchema, filtersGreResponseSchema, filtersRecursiveResponseSchema } from "@/lib/filters-response"
+import { firewallServersSchema } from "@/lib/firewall-response"
+import { requestJson } from "@/shared/api/http-client"
+import {
+ servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog,
+ type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel,
+} from "@/lib/data"
+import { useDataSource } from "@/lib/data-source"
+import { can } from "@/lib/auth"
+import { useEvoBGP } from "@/lib/evobgp-context"
+import {
+ PlusIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon,
+ PencilIcon, TrashIcon, ChevronUpIcon, ChevronDownIcon,
+ StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
+ FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
+ ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
+ RefreshCwIcon, HistoryIcon,
+} from "lucide-react"
+import {
+ Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
+} from "@/components/ui/sheet"
+import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { toast } from "sonner"
+import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
+import { ConfigHistorySheet } from "@/components/config-history-sheet"
+import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { type ServerTileItem } from "@/components/server-tile-rail"
+
+// ── helpers ────────────────────────────────────────────────────────────────────
+
+function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
+function innerIpToGateway(ip: string) { return ip.split("/")[0] }
+
+/** В BGP routing filter для рекурсивного маршрута — set gateway к хопу из dst-address (напр. 10.9.9.2/32 → 10.9.9.2) */
+function gatewayFromRecursiveDst(dstAddress: string): string {
+ return innerIpToGateway((dstAddress ?? "").trim())
+}
+
+/** Если в правиле нет rec: (например после sync с роутера до обогащения на бэкенде), находим строку по совпадению hop с dst-prefix */
+function pickRecursiveRouteByGatewayHop(
+ routes: RecursiveRouteLite[],
+ hopIp: string,
+): RecursiveRouteLite | undefined {
+ const hop = hopIp.trim()
+ if (!hop) return undefined
+ const candidates = routes.filter(
+ r => !r.disabled && gatewayFromRecursiveDst(r.dstAddress) === hop,
+ )
+ if (candidates.length === 0) return undefined
+ return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
+}
+
+/** Same as backend: IP%iface из поля gateway рекурсивного маршрута */
+function splitRecursiveGateway(raw: string): { ip: string; iface: string } {
+ const v = (raw ?? "").trim()
+ if (!v) return { ip: "", iface: "" }
+ const pct = v.indexOf("%")
+ const left = pct >= 0 ? v.slice(0, pct) : v
+ const iface = pct >= 0 ? v.slice(pct + 1).trim() : ""
+ const ip = left.split("/")[0]?.trim() ?? ""
+ return { ip, iface }
+}
+
+function isRecursiveGatewayRef(ref: string): boolean {
+ return ref.startsWith("rec:")
+}
+
+/** Один ключ на префикс назначения — несколько записей с тем же dst-address (разные via, резерв) = один логический маршрут */
+function normalizeRecursiveDstAddress(raw: string): string {
+ return (raw ?? "").trim().toLowerCase()
+}
+
+/**
+ * Рекурсивный маршрут в RouterOS может быть представлен несколькими статическими записями
+ * на один и тот же dst-address (разный gateway / резервирование). Для выбора в фильтре нужна
+ * одна строка на уникальный dst-address; при совпадении оставляем меньший distance.
+ */
+function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): RecursiveRouteLite[] {
+ const byDst = new Map()
+ for (const r of routes) {
+ if (r.disabled) continue
+ const key = normalizeRecursiveDstAddress(r.dstAddress)
+ if (!key) continue
+ const prev = byDst.get(key)
+ if (!prev) {
+ byDst.set(key, r)
+ continue
+ }
+ if (r.distance < prev.distance) byDst.set(key, r)
+ }
+ return Array.from(byDst.values()).sort((a, b) => {
+ if (a.distance !== b.distance) return a.distance - b.distance
+ return normalizeRecursiveDstAddress(a.dstAddress).localeCompare(normalizeRecursiveDstAddress(b.dstAddress))
+ })
+}
+
+const COMMUNITY_NAMES: Record = {
+ "65001:100": "youtube-bypass",
+ "65001:200": "streaming-eu",
+ "65001:300": "cdn-bypass",
+ "65002:100": "cdn-secondary",
+ "65001:400": "office-direct",
+ "65001:500": "gaming-ll",
+ "65002:200": "gaming-ll-backup",
+ "65007:999": "school-block",
+ // blackhole communities
+ "65535:666": "BLACKHOLE (RFC 7999)",
+ "65001:666": "blackhole-local",
+ "65002:666": "blackhole-peer",
+ "no-export": "no-export",
+ "no-advertise": "no-advertise",
+}
+
+// Communities that imply blackhole action by default
+const BLACKHOLE_COMMUNITIES = new Set(["65535:666", "65001:666", "65002:666"])
+// ── RouterOS config generator (combined, all servers) ─────────────────────────
+//
+// Единый скрипт для всех MikroTik (RouterOS 7).
+// Роутер определяет себя через [/system identity get name].
+// Все community → gateway маппинги — ОДИН filter rule на сервер
+// с ветвлением if/else if внутри параметра rule="...".
+
+function generateCombinedRouterOSConfig(
+ rulesets: ServerFilterRuleset[],
+ serversList: Server[],
+ tunnelsList: GreTunnel[],
+ recRoutesByServer: Record,
+): string {
+ const now = new Date().toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" })
+ const withRules = rulesets.filter(r => r.rules.length > 0)
+ const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
+ const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
+
+ const lines: string[] = [
+ `# MikrotikManager — конфиг фильтрации`,
+ `# Дата : ${now}`,
+ `# Серверов : ${withRules.length}`,
+ `# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
+ ``,
+ ]
+
+ if (withRules.length === 0) {
+ lines.push(`# Нет правил фильтрации.`)
+ } else {
+ withRules.forEach((rs, si) => {
+ const server = serversList.find(s => s.id === rs.serverId)
+ if (!server) return
+
+ lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`)
+
+ const recList = recRoutesByServer[rs.serverId] ?? []
+ // Группировка по эффекту: communities с одинаковым `set gw` (+ интерфейс) объединяются
+ // в один if-блок через `||` — компактнее и привычнее для bgp-in.
+ // RouterOS не поддерживает `else if`, поэтому между группами — независимые `if`-блоки;
+ // `accept;` в первом совпавшем блоке завершает обработку, дальнейшие if не выполняются.
+ type Group = {
+ isBlackhole: boolean
+ gateway: string
+ outIface: string
+ items: Array<{ community: string; desc: string }>
+ }
+ const groups: Group[] = []
+ const indexByKey = new Map()
+
+ for (const rule of rs.rules) {
+ const isBlackhole = rule.action === "blackhole"
+ const label = rule.communityName ?? rule.community
+ const desc = rule.description ? `${label} — ${rule.description}` : label
+
+ let gw = ""
+ let iface = ""
+ if (!isBlackhole) {
+ if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
+ const rid = rule.gatewayTunnelId.slice(4)
+ const rr = recList.find(r => r.id === rid)
+ gw = rr ? gatewayFromRecursiveDst(rr.dstAddress) : rule.gateway
+ } else {
+ const tunnel = tunnelsList.find(t => t.id === rule.gatewayTunnelId)
+ gw = rule.gateway
+ iface = tunnel ? tunnel.name : "unknown"
+ }
+ }
+ const key = isBlackhole ? "bh" : `rt:${gw}:${iface}`
+ let idx = indexByKey.get(key)
+ if (idx === undefined) {
+ idx = groups.length
+ indexByKey.set(key, idx)
+ groups.push({ isBlackhole, gateway: gw, outIface: iface, items: [] })
+ }
+ groups[idx].items.push({ community: rule.community, desc })
+ }
+
+ const branches = groups.map(g => {
+ const cond = g.items
+ .map(it => `bgp-communities includes ${it.community}`)
+ .join(" || ")
+ const commentLines = g.items.map(it => ` # ${it.community}: ${it.desc}`)
+
+ if (g.isBlackhole) {
+ return [
+ ` if (${cond}) {`,
+ ...commentLines,
+ ` set type blackhole;`,
+ ` accept;`,
+ ` }`,
+ ].join("\n")
+ }
+ const out = [
+ ` if (${cond}) {`,
+ ...commentLines,
+ ` set gw ${g.gateway};`,
+ ]
+ if (g.outIface) out.push(` set out-interface ${g.outIface};`)
+ out.push(` accept;`)
+ out.push(` }`)
+ return out.join("\n")
+ }).join("\n")
+
+ lines.push(`/routing filter rule add \\`)
+ lines.push(` chain=bgp-in \\`)
+ lines.push(` comment="MikrotikManager: ${server.name}" \\`)
+ lines.push(` rule="`)
+ lines.push(branches)
+ lines.push(` "`)
+
+ if (si < withRules.length - 1) lines.push(``)
+ })
+ }
+
+ return lines.join("\n")
+}
+
+// ── shared components ──────────────────────────────────────────────────────────
+
+function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
+ return (
+
+ {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
+
+ )
+}
+
+// ── GRE gateway selector ───────────────────────────────────────────────────────
+
+function GatewayCards({
+ serverId, selected, onSelect, tunnelsList, serversList,
+}: {
+ serverId: string
+ selected: string
+ onSelect: (gw: string, tunnelId: string) => void
+ tunnelsList: GreTunnel[]
+ serversList: Server[]
+}) {
+ const tunnels = tunnelsList.filter(t => t.serverId === serverId)
+
+ if (tunnels.length === 0) {
+ return (
+
+ Нет GRE-туннелей на этом сервере.
+
+ Добавьте туннели на странице «GRE-туннели»
+
+ )
+ }
+
+ return (
+
+ {tunnels.map(t => {
+ const gw = innerIpToGateway(t.remoteInnerIp)
+ const isSelected = selected === gw
+ const remoteSrv = serversList.find(s => s.host === t.remoteAddress)
+ return (
+
onSelect(gw, t.id)}
+ disabled={t.status === "down"}
+ className={cn(
+ "text-left rounded-lg border px-3 py-2.5 transition-all",
+ isSelected
+ ? "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
+ t.status === "down" && "opacity-40 cursor-not-allowed",
+ )}>
+
+
+ {remoteSrv && }
+ {t.name}
+ {isSelected && }
+
+
+ local
+ {innerIpToGateway(t.localInnerIp)}
+ →
+ {gw}
+
+
+ )
+ })}
+
+ )
+}
+
+function RecursiveGatewayCards({
+ selectedTunnelId,
+ routes,
+ onSelect,
+}: {
+ selectedTunnelId: string
+ routes: RecursiveRouteLite[]
+ onSelect: (route: RecursiveRouteLite) => void
+}) {
+ const list = useMemo(() => dedupeRecursiveRoutesByDstAddress(routes), [routes])
+
+ /** Подсветка, если в правиле сохранён rec:id «дубликата», убранного из списка — совпадение по dst-address */
+ const selectedDstNorm = useMemo(() => {
+ if (!isRecursiveGatewayRef(selectedTunnelId)) return null
+ const id = selectedTunnelId.slice(4)
+ const raw = routes.find(r => r.id === id)
+ return raw ? normalizeRecursiveDstAddress(raw.dstAddress) : null
+ }, [selectedTunnelId, routes])
+
+ if (list.length === 0) {
+ return (
+
+ Нет рекурсивных маршрутов для этого сервера.
+
+
+ Добавьте их в «Рекурсивные маршруты» и синхронизируйте с роутером
+
+
+ )
+ }
+
+ return (
+
+ {list.map(r => {
+ const ref = `rec:${r.id}`
+ const filterGw = gatewayFromRecursiveDst(r.dstAddress)
+ const { ip: viaIp } = splitRecursiveGateway(r.gateway)
+ const sel =
+ selectedTunnelId === ref ||
+ (!!selectedDstNorm && normalizeRecursiveDstAddress(r.dstAddress) === selectedDstNorm)
+ return (
+
onSelect(r)}
+ className={cn(
+ "text-left rounded-lg border px-3 py-2.5 transition-all",
+ sel
+ ? "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
+ )}
+ >
+
+
+ {r.dstAddress}
+ {sel && }
+
+
+
+ set gateway
+ {filterGw}
+
+
+ via
+ {viaIp || r.gateway}
+
+ {r.routingTable !== "main" && (
+
table {r.routingTable}
+ )}
+ {r.comment ? (
+
{r.comment}
+ ) : null}
+
+
+ )
+ })}
+
+ )
+}
+
+// ── community autocomplete ─────────────────────────────────────────────────────
+
+function CommunityInput({ value, onChange, suggestions, nameMap, inputId, disabled }: {
+ value: string; onChange: (v: string) => void; suggestions: string[]; nameMap: Record
+ inputId: string; disabled: boolean
+}) {
+ return { if (v !== null) onChange(v) }}
+ filter={(item, query) => item.toLowerCase().includes(query.toLowerCase()) || (nameMap[item] ?? "").toLowerCase().includes(query.toLowerCase())}>
+
+
+ Нет подсказок — можно ввести Community вручную
+ {(community: string) =>
+ {community} {nameMap[community]}
+ }
+
+
+}
+
+// ── rule sheet ─────────────────────────────────────────────────────────────────
+
+interface RuleForm {
+ action: "route" | "blackhole"
+ community: string
+ gateway: string
+ gatewayTunnelId: string
+ /** Только UI: GRE-туннель или рекурсивный маршрут (хранится как rec:id в gatewayTunnelId) */
+ gatewayKind: "gre" | "recursive"
+ description: string
+}
+const emptyForm = (): RuleForm => ({
+ action: "route",
+ community: "",
+ gateway: "",
+ gatewayTunnelId: "",
+ gatewayKind: "gre",
+ description: "",
+})
+
+function RuleSheet({
+ open, mode, initial, serverId, onSave, onClose, serversList, tunnelsList,
+ communityNameMap,
+ communitySuggestions,
+ evoHint,
+ recursiveRoutes,
+}: {
+ open: boolean; mode: "create" | "edit"
+ initial: RuleForm; serverId: string
+ onSave: (f: RuleForm) => Promise; onClose: () => void
+ serversList: Server[]
+ tunnelsList: GreTunnel[]
+ communityNameMap: Record
+ communitySuggestions: string[]
+ evoHint?: string | null
+ recursiveRoutes: RecursiveRouteLite[]
+}) {
+ const communityInputId = useId()
+ const [form, setForm] = useState(initial)
+ const [error, setError] = useState(null)
+ const [touched, setTouched] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const saveLock = useRef(false)
+
+ const set = (k: K, v: RuleForm[K]) => {
+ setForm(f => ({ ...f, [k]: v }))
+ if (touched) validate_({ ...form, [k]: v })
+ }
+
+ // When community changes, auto-detect blackhole communities
+ const handleCommunityChange = (v: string) => {
+ const next: RuleForm = {
+ ...form,
+ community: v,
+ action: BLACKHOLE_COMMUNITIES.has(v) ? "blackhole" : form.action,
+ }
+ setForm(next)
+ if (touched) validate_(next)
+ }
+
+ const validate_ = (f: RuleForm) => {
+ if (!f.community.trim()) {
+ setError("BGP Community обязательно"); return false
+ }
+ if (f.action === "route" && !f.gatewayTunnelId.trim()) {
+ setError(f.gatewayKind === "recursive" ? "Выберите рекурсивный маршрут" : "Выберите GRE-шлюз")
+ return false
+ }
+ setError(null); return true
+ }
+ const handleSave = async () => {
+ if (saveLock.current) return
+ setTouched(true)
+ if (!validate_(form)) return
+ saveLock.current = true
+ setSaving(true)
+ setSaveError(null)
+ try {
+ setSaveError(await onSave(form.action === "blackhole"
+ ? { ...form, gateway: "", gatewayTunnelId: "", gatewayKind: "gre" }
+ : form
+ ))
+ } catch (error) {
+ setSaveError(error instanceof Error ? error.message : "Не удалось сохранить правило")
+ } finally {
+ saveLock.current = false
+ setSaving(false)
+ }
+ }
+ const close = () => { if (!saveLock.current) onClose() }
+
+ const isBlackhole = form.action === "blackhole"
+
+ return (
+ { if (!v) close() }}>
+
+
+
+
+
+
+ {mode === "create" ? "Новое правило" : "Редактировать правило"}
+
+
+ Community →{" "}
+ {isBlackhole
+ ? "Blackhole"
+ : form.gatewayKind === "recursive"
+ ? "рекурсивный маршрут"
+ : "GRE-шлюз"}{" "}
+ · {serversList.find(s => s.id === serverId)?.name}
+
+
+
+
+
+
+
+
+
+
+ {/* ── BGP Community ── */}
+
+
+
+ BGP Community *
+
+
+ {form.community && communityNameMap[form.community] && (
+
+ {communityNameMap[form.community]}
+
+ )}
+ {evoHint && (
+
+ {evoHint}
+
+ )}
+
Формат: AS:NNN · RFC 1997 / RFC 7999 · подсказки из EvoBGP при включённой интеграции
+
+
+
+
+ {/* ── Action toggle ── */}
+
+
Действие
+
+ {(["route", "blackhole"] as const).map(a => (
+
set("action", a)}
+ className={cn(
+ "flex flex-col items-start gap-1.5 rounded-lg border px-3 py-3 text-left transition-all",
+ form.action === a
+ ? a === "blackhole"
+ ? "border-red-500/50 bg-red-500/8 ring-1 ring-red-500/30"
+ : "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
+ )}
+ >
+
+
+ {a === "route" ? "Маршрутизировать" : "⊘ Blackhole"}
+
+ {form.action === a && }
+
+
+ {a === "route"
+ ? "Next-hop через GRE или рекурсивный маршрут"
+ : "set type blackhole в таблице маршрутов"}
+
+
+ ))}
+
+
+
+ {/* ── GRE / recursive gateway (only for route action) ── */}
+ {!isBlackhole && (
+ <>
+
+
+
+ Выход маршрута *
+
+
{
+ const k = v as "gre" | "recursive"
+ setForm(f => {
+ const next = { ...f, gatewayKind: k, gateway: "", gatewayTunnelId: "" }
+ if (touched) validate_(next)
+ return next
+ })
+ }}
+ className="flex flex-col gap-3"
+ >
+
+
+
+ GRE-туннель
+
+
+
+ Рекурсивный
+
+
+
+ {
+ setForm(f => {
+ const next = { ...f, gatewayKind: "gre" as const, gateway: gw, gatewayTunnelId: tid }
+ if (touched) validate_(next)
+ return next
+ })
+ }}
+ />
+
+
+ {
+ setForm(f => {
+ const next = {
+ ...f,
+ gatewayKind: "recursive" as const,
+ gateway: gatewayFromRecursiveDst(r.dstAddress),
+ gatewayTunnelId: `rec:${r.id}`,
+ }
+ if (touched) validate_(next)
+ return next
+ })
+ }}
+ />
+
+
+ {touched && form.action === "route" && !form.gatewayTunnelId.trim() && (
+
+
+ {form.gatewayKind === "recursive"
+ ? "Выберите рекурсивный маршрут"
+ : "Выберите GRE-шлюз"}
+
+ )}
+
+ >
+ )}
+
+ {/* ── Blackhole info panel ── */}
+ {isBlackhole && (
+
+
RTBH — Remote Triggered Black Hole
+
+ Маршруты с этим community будут установлены с типом blackhole в таблице маршрутизации RouterOS.
+ Трафик на совпадающие префиксы будет молча сброшен.
+
+
+ if
+ {" (bgp-communities includes "}
+ {form.community || "AS:NNN"}
+ {") {\n "}
+ set type blackhole
+ {";\n accept;\n}"}
+
+
+ )}
+
+
+
+
+ Описание (опционально)
+ set("description", e.target.value)}
+ placeholder={isBlackhole ? "RTBH — нулевая маршрутизация DDoS-трафика" : "YouTube bypass через Франкфурт"}
+ className="h-9 text-sm" />
+
+
+ {error && (
+
+ )}
+
+ {saveError && {saveError}
}
+ {saving && Ожидание подтверждения сервера…
}
+
+
+ Отмена
+ void handleSave()}
+ disabled={saving}
+ className={cn("flex-1", isBlackhole && "bg-red-600 hover:bg-red-700 text-white border-0")}
+ >
+
+ {saving ? "Сохранение…" : mode === "create" ? "Добавить" : "Сохранить"}
+
+
+
+
+ )
+}
+
+// ── config preview modal ───────────────────────────────────────────────────────
+
+function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsList, recRoutesByServer }: {
+ open: boolean
+ serverId: string
+ rulesets: ServerFilterRuleset[]
+ onClose: () => void
+ serversList: Server[]
+ tunnelsList: GreTunnel[]
+ recRoutesByServer: Record
+}) {
+ const singleRuleset = useMemo(
+ () => rulesets.filter(r => r.serverId === serverId),
+ [rulesets, serverId],
+ )
+ const server = serversList.find(s => s.id === serverId)
+ const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
+
+ const config = useMemo(
+ () =>
+ open
+ ? generateCombinedRouterOSConfig(singleRuleset, serversList, tunnelsList, recRoutesByServer)
+ : "",
+ [open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
+ )
+
+ return (
+
+ )
+}
+
+// ── Copy Rules Sheet ──────────────────────────────────────────────────────────
+
+type CopyMode = "append" | "replace"
+type GatewayReadiness = Record>>
+
+/** Try to find an equivalent tunnel on the target server (same remoteAddress). */
+function mapTunnel(
+ sourceTunnelId: string,
+ targetServerId: string,
+ tunnelsList: GreTunnel[],
+): { tunnelId: string; gateway: string } | null {
+ const src = tunnelsList.find(t => t.id === sourceTunnelId)
+ if (!src) return null
+ const dst = tunnelsList.find(
+ t => t.serverId === targetServerId && t.remoteAddress === src.remoteAddress,
+ )
+ if (!dst) return null
+ return { tunnelId: dst.id, gateway: innerIpToGateway(dst.remoteInnerIp) }
+}
+
+/** Лучший кандидат на целевом сервере: тот же dst-address и routing-table (несколько via — меньший distance) */
+function pickRecursiveRouteForCopy(
+ src: RecursiveRouteLite,
+ targetRoutes: RecursiveRouteLite[],
+): RecursiveRouteLite | undefined {
+ const candidates = targetRoutes.filter(
+ r =>
+ r.dstAddress === src.dstAddress &&
+ r.routingTable === src.routingTable &&
+ !r.disabled,
+ )
+ if (candidates.length === 0) return undefined
+ return candidates.reduce((a, b) => (a.distance <= b.distance ? a : b))
+}
+
+/** Сопоставить рекурсивный маршрут на целевом сервере по dst + table; gateway для фильтра — из dst-address */
+function mapRecursiveGatewayForCopy(
+ gatewayTunnelId: string,
+ sourceRoutes: RecursiveRouteLite[],
+ targetRoutes: RecursiveRouteLite[],
+): { gateway: string; gatewayTunnelId: string } {
+ const sid = gatewayTunnelId.slice(4)
+ const src = sourceRoutes.find(r => r.id === sid)
+ if (!src) return { gateway: "", gatewayTunnelId: "" }
+ const tgt = pickRecursiveRouteForCopy(src, targetRoutes)
+ if (!tgt) {
+ return { gateway: gatewayFromRecursiveDst(src.dstAddress), gatewayTunnelId: "" }
+ }
+ return {
+ gateway: gatewayFromRecursiveDst(tgt.dstAddress),
+ gatewayTunnelId: `rec:${tgt.id}`,
+ }
+}
+
+function CopyRulesSheet({
+ open, onClose,
+ sourceServerId, sourceRules,
+ rulesets, onCopy, serversList,
+ greByServer,
+ ensureGreFor,
+ recRoutesByServer,
+ ensureRecursiveFor,
+ catalogReady,
+}: {
+ open: boolean; onClose: () => void
+ sourceServerId: string; sourceRules: FilterRule[]
+ rulesets: ServerFilterRuleset[]
+ onCopy: (targetServerId: string, rules: FilterRule[], mode: CopyMode) => Promise
+ serversList: Server[]
+ greByServer: Record
+ ensureGreFor?: (serverId: string) => void
+ recRoutesByServer: Record
+ ensureRecursiveFor?: (serverId: string) => void
+ catalogReady?: GatewayReadiness
+}) {
+ const [selected, setSelected] = useState>(() => new Set(sourceRules.map(r => r.id)))
+ const [targetId, setTargetId] = useState(null)
+ const [mode, setMode] = useState("append")
+ const [copying, setCopying] = useState(false)
+ const [copyError, setCopyError] = useState(null)
+ const copyLock = useRef(false)
+
+ const sourceRecursiveRoutes = recRoutesByServer[sourceServerId] ?? []
+ const targetRecursiveRoutes = targetId ? (recRoutesByServer[targetId] ?? []) : []
+
+ useEffect(() => {
+ if (!open || !ensureGreFor) return
+ ensureGreFor(sourceServerId)
+ }, [open, sourceServerId, ensureGreFor])
+
+ useEffect(() => {
+ if (!open || !targetId || !ensureGreFor) return
+ ensureGreFor(targetId)
+ }, [open, targetId, ensureGreFor])
+
+ useEffect(() => {
+ if (!open || !ensureRecursiveFor) return
+ ensureRecursiveFor(sourceServerId)
+ }, [open, sourceServerId, ensureRecursiveFor])
+
+ useEffect(() => {
+ if (!open || !targetId || !ensureRecursiveFor) return
+ ensureRecursiveFor(targetId)
+ }, [open, targetId, ensureRecursiveFor])
+
+ const tunnelsList = useMemo(
+ () => [
+ ...(greByServer[sourceServerId] ?? []),
+ ...(targetId ? (greByServer[targetId] ?? []) : []),
+ ],
+ [greByServer, sourceServerId, targetId],
+ )
+
+ const sourceServer = serversList.find(s => s.id === sourceServerId)!
+ const targetServers = serversList.filter(s => s.id !== sourceServerId)
+
+ const selectedRules = sourceRules.filter(r => selected.has(r.id))
+ const targetRules = targetId
+ ? (rulesets.find(r => r.serverId === targetId)?.rules ?? [])
+ : []
+
+ // Compute gateway mapping for each selected route rule
+ const mapping = useMemo(() => {
+ if (!targetId) return []
+ return selectedRules
+ .filter(r =>
+ (r.action ?? "route") === "route" &&
+ r.gatewayTunnelId &&
+ !isRecursiveGatewayRef(r.gatewayTunnelId),
+ )
+ .map(r => {
+ const srcTunnel = tunnelsList.find(t => t.id === r.gatewayTunnelId)
+ const dstMapping = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList)
+ const dstTunnel = dstMapping ? tunnelsList.find(t => t.id === dstMapping.tunnelId) : undefined
+ return { rule: r, srcTunnel, dstTunnel, mapped: !!dstMapping }
+ })
+ // deduplicate by source tunnel
+ .filter((m, i, arr) =>
+ arr.findIndex(x => x.srcTunnel?.id === m.srcTunnel?.id) === i
+ )
+ }, [selectedRules, targetId, tunnelsList])
+
+ const unmappedCount = mapping.filter(m => !m.mapped).length
+
+ function toggleRule(id: string) {
+ setSelected(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id); else next.add(id)
+ return next
+ })
+ }
+ function toggleAll() {
+ setSelected(prev =>
+ prev.size === sourceRules.length
+ ? new Set()
+ : new Set(sourceRules.map(r => r.id))
+ )
+ }
+
+ async function handleCopy() {
+ if (copyLock.current || !targetId || selectedRules.length === 0 || !mappingReady) return
+ copyLock.current = true
+ setCopying(true)
+ setCopyError(null)
+ const copied: FilterRule[] = selectedRules.map(r => {
+ if ((r.action ?? "route") === "blackhole") {
+ return { ...r, id: newId() }
+ }
+ if (isRecursiveGatewayRef(r.gatewayTunnelId)) {
+ const m = mapRecursiveGatewayForCopy(r.gatewayTunnelId, sourceRecursiveRoutes, targetRecursiveRoutes)
+ return {
+ ...r,
+ id: newId(),
+ gateway: m.gateway,
+ gatewayTunnelId: m.gatewayTunnelId,
+ }
+ }
+ const m = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList)
+ return {
+ ...r,
+ id: newId(),
+ gateway: m?.gateway ?? "",
+ gatewayTunnelId: m?.tunnelId ?? "",
+ }
+ })
+ try {
+ const error = await onCopy(targetId, copied, mode)
+ if (error) setCopyError(error)
+ else onClose()
+ } catch (error) {
+ setCopyError(error instanceof Error ? error.message : "Не удалось скопировать правила")
+ } finally {
+ copyLock.current = false
+ setCopying(false)
+ }
+ }
+
+ const close = () => { if (!copyLock.current) onClose() }
+ const requiredKinds = selectedRules.filter(r => (r.action ?? "route") === "route").map(r => isRecursiveGatewayRef(r.gatewayTunnelId) ? "recursive" as const : "gre" as const)
+ const catalogsReady = !catalogReady || (targetId !== null && requiredKinds.every(kind => catalogReady[sourceServerId]?.[kind] && catalogReady[targetId]?.[kind]))
+ const mappingReady = catalogsReady && selectedRules.every(rule => {
+ if ((rule.action ?? "route") === "blackhole") return true
+ if (!targetId) return false
+ if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
+ const source = sourceRecursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4))
+ return !!source && !!pickRecursiveRouteForCopy(source, targetRecursiveRoutes)
+ }
+ return !!mapTunnel(rule.gatewayTunnelId, targetId, tunnelsList)
+ })
+ const canCopy = targetId !== null && selectedRules.length > 0 && !copying && mappingReady
+
+ return (
+ { if (!v) close() }}>
+
+
+ {/* header */}
+
+
+
+
+
+ Копировать правила
+
+
+
+ {sourceServer.name}
+
+ целевой сервер
+
+
+
+
+
+
+
+
+
+
+ {/* ── rule selection ── */}
+
+
+
Правила для копирования
+
+ {selected.size === sourceRules.length ? "Снять все" : "Выбрать все"}
+
+
+
+
+ {sourceRules.map(r => {
+ const isBlackhole = r.action === "blackhole"
+ const tunnel = !isBlackhole && !isRecursiveGatewayRef(r.gatewayTunnelId)
+ ? tunnelsList.find(t => t.id === r.gatewayTunnelId)
+ : undefined
+ const rec = !isBlackhole && isRecursiveGatewayRef(r.gatewayTunnelId)
+ ? sourceRecursiveRoutes.find(x => x.id === r.gatewayTunnelId.slice(4))
+ : undefined
+ const isSelected = selected.has(r.id)
+ return (
+ toggleRule(r.id)}
+ className={cn(
+ "flex items-center gap-2.5 px-3 py-2 rounded-lg border text-left transition-all",
+ isSelected
+ ? "bg-muted/50 border-border"
+ : "border-transparent opacity-40 hover:opacity-70",
+ )}>
+ {/* checkbox */}
+
+ {isSelected && }
+
+ {/* community */}
+
+ {r.community}
+
+ {/* action / tunnel */}
+ {isBlackhole ? (
+ ⊘ blackhole
+ ) : rec ? (
+
+ {rec.dstAddress} → {gatewayFromRecursiveDst(rec.dstAddress)}
+
+ ) : tunnel ? (
+ {tunnel.name}
+ ) : null}
+ {/* description */}
+ {r.description}
+
+ )
+ })}
+
+
+
+ Выбрано: {selected.size} из {sourceRules.length}
+
+
+
+ {/* ── target server ── */}
+
+
Целевой сервер
+
+ {targetServers.map(s => {
+ const ruleCount = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
+ const isTarget = targetId === s.id
+ return (
+ setTargetId(s.id)}
+ className={cn(
+ "flex items-center gap-2.5 px-3 py-2.5 rounded-lg border text-left transition-all",
+ isTarget
+ ? "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
+ )}>
+
+
+ {s.name}
+
+
+ {ruleCount} правил
+
+ {isTarget && }
+
+ )
+ })}
+
+
+
+ {/* ── copy mode ── */}
+ {targetId && (
+
+
Режим копирования
+
+ {([
+ { v: "append" as CopyMode, label: "Добавить", sub: `+${selectedRules.length} к ${targetRules.length} существующим` },
+ { v: "replace" as CopyMode, label: "Заменить", sub: `${targetRules.length} правил → ${selectedRules.length} новых` },
+ ] as const).map(opt => (
+
setMode(opt.v)}
+ className={cn(
+ "flex flex-col gap-1 text-left px-3 py-2.5 rounded-lg border transition-all",
+ mode === opt.v
+ ? opt.v === "replace"
+ ? "border-amber-500/50 bg-amber-500/5 ring-1 ring-amber-500/20"
+ : "border-primary bg-primary/5 ring-1 ring-primary/30"
+ : "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
+ )}>
+
+ {opt.label}
+ {mode === opt.v && }
+
+ {opt.sub}
+
+ ))}
+
+
+ {mode === "replace" && targetRules.length > 0 && (
+
+
+ {targetRules.length} существующих правил на {serversList.find(s => s.id === targetId)?.name} будут удалены
+
+ )}
+
+ )}
+
+ {/* ── gateway mapping preview ── */}
+ {targetId && mapping.length > 0 && (
+
+
+ Маппинг GRE-туннелей
+ {unmappedCount > 0 && (
+
+ · {unmappedCount} не найдено
+
+ )}
+
+
+ {mapping.map((m, i) => (
+
+ {/* source tunnel */}
+
{m.srcTunnel?.name ?? "—"}
+
+ {/* target tunnel */}
+ {m.mapped ? (
+
+ {m.dstTunnel?.name}
+
+ ) : (
+
+ туннель не найден — шлюз будет пустым
+
+ )}
+ {m.mapped
+ ?
+ :
+ }
+
+ ))}
+ {mapping.some(m => !m.mapped) && (
+
+ Копирование недоступно, пока для каждого маршрута не найден шлюз целевого устройства
+
+ )}
+
+
+ )}
+
+
+ {targetId && !mappingReady &&
+
Каталоги или сопоставление шлюзов не подтверждены. Копирование маршрутов заблокировано.
+
{ for (const id of [sourceServerId, targetId]) { ensureGreFor?.(id); ensureRecursiveFor?.(id) } }}>Обновить шлюзы
+
}
+ {copyError && {copyError}
}
+ {copying && Ожидание подтверждения копирования…
}
+
+ {/* footer */}
+
+ Отмена
+ void handleCopy()}
+ disabled={!canCopy}
+ className="flex-1"
+ >
+ {copying
+ ? <> Копирование…>
+ : <> Копировать {selectedRules.length} правил>
+ }
+
+
+
+
+ )
+}
+
+// ── page ───────────────────────────────────────────────────────────────────────
+
+function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
+ return serverList.map(s => ({
+ serverId: s.id,
+ rules: sourceRulesets.find(r => r.serverId === s.id)?.rules ?? [],
+ }))
+}
+
+function makeApiFetch(backendUrl: string) {
+ return async function apiFetch(path: string, init?: RequestInit): Promise {
+ return requestJson(backendUrl, path, init)
+ }
+}
+
+function FiltersPage() {
+ const { mode, backendUrl } = useDataSource()
+ const mounted = useRef(true)
+ useEffect(() => {
+ mounted.current = true
+ return () => { mounted.current = false }
+ }, [])
+ const evo = useEvoBGP()
+ /** Как на `/servers`: живые данные при режиме «Живые», без ожидания отдельного health-check */
+ const isLive = mode === "live"
+ const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
+
+ const communityNameMap = useMemo(() => {
+ if (mode === "live") {
+ const m: Record = {}
+ for (const c of evo.enabled && can("mm:settings:admin") ? evo.snapshot?.communities ?? [] : []) {
+ const v = (c.value ?? "").trim()
+ if (!v) continue
+ m[v] = c.name?.trim() || v
+ }
+ return m
+ }
+ return { ...COMMUNITY_NAMES }
+ }, [mode, evo.enabled, evo.snapshot?.communities])
+
+ const communitySuggestions = useMemo(
+ () => Object.keys(communityNameMap).sort((a, b) => a.localeCompare(b)),
+ [communityNameMap],
+ )
+
+ const evoCommunityHint = useMemo(() => {
+ if (mode !== "live") return null
+ if (!can("mm:settings:admin")) return "Справочник EvoBGP недоступен по правам; значение community можно ввести вручную"
+ if (!evo.settingsLoaded) return "Настройки справочника EvoBGP неизвестны; значение community можно ввести вручную"
+ if (!evo.enabled) return "Интеграция EvoBGP выключена; значение community можно ввести вручную"
+ if (evo.error) return "Справочник EvoBGP не обновлён: " + evo.error
+ if (evo.loading) return "Загрузка справочника communities из EvoBGP…"
+ return null
+ }, [mode, evo.settingsLoaded, evo.enabled, evo.error, evo.loading])
+
+ const [liveServers, setLiveServers] = useState([])
+ /** GRE с роутера только по запрошенным serverId (текущий + цели копирования) */
+ const [greByServer, setGreByServer] = useState>({})
+ const [recRoutesByServer, setRecRoutesByServer] = useState>({})
+ const [rulesets, setRulesets] = useState(
+ () => isLive ? [] : buildRulesets(servers, serverFilterRulesets),
+ )
+ const [liveLoadState, setLiveLoadState] = useState<"idle" | "loading" | "error">(
+ () => (mode === "live" ? "loading" : "idle"),
+ )
+
+ const [liveLoadError, setLiveLoadError] = useState(null)
+ const [loadAttempt, setLoadAttempt] = useState(0)
+
+ const [gatewayReady, setGatewayReady] = useState({})
+ const [rulesReceipt, setRulesReceipt] = useState>({})
+ const [gatewayReceipt, setGatewayReceipt] = useState>>>({})
+ const [gatewayErrors, setGatewayErrors] = useState>>>({})
+ const gatewayEpoch = useRef>({})
+
+ const loadGatewayCatalog = useCallback(async (serverId: string, kind: "gre" | "recursive") => {
+ if (!isLive || !mounted.current) return
+ const key = serverId + ":" + kind
+ const epoch = (gatewayEpoch.current[key] ?? 0) + 1
+ gatewayEpoch.current[key] = epoch
+ setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: false } }))
+ try {
+ const path = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes"
+ const payload = await apiFetch(`${path}?serverId=${encodeURIComponent(serverId)}`)
+ if (!mounted.current || gatewayEpoch.current[key] !== epoch) return
+ if (kind === "gre") {
+ const parsed = filtersGreResponseSchema.safeParse(payload)
+ if (!parsed.success || parsed.data.tunnels.some(t => t.serverId !== serverId)) throw new Error("Некорректный каталог GRE")
+ setGreByServer(prev => ({ ...prev, [serverId]: parsed.data.tunnels }))
+ } else {
+ const parsed = filtersRecursiveResponseSchema.safeParse(payload)
+ if (!parsed.success || parsed.data.stale) throw new Error("Рекурсивные шлюзы недоступны или устарели")
+ setRecRoutesByServer(prev => ({ ...prev, [serverId]: parsed.data.routes }))
+ }
+ setGatewayReceipt(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: new Date().toISOString() } }))
+ setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: true } }))
+ setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: undefined } }))
+ } catch (error) {
+ if (!mounted.current || gatewayEpoch.current[key] !== epoch) return
+ setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: error instanceof Error ? error.message : "Каталог шлюзов недоступен" } }))
+ }
+ }, [isLive, apiFetch])
+ const ensureGreTunnels = useCallback((serverId: string) => loadGatewayCatalog(serverId, "gre"), [loadGatewayCatalog])
+ const ensureRecursiveRoutes = useCallback((serverId: string) => loadGatewayCatalog(serverId, "recursive"), [loadGatewayCatalog])
+ const [selectedServerId, setSelectedServerId] = useState("srv1")
+ const [search, setSearch] = useState("")
+ const [sheetOpen, setSheetOpen] = useState(false)
+ const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
+ const [sheetInitial, setSheetInitial]= useState(emptyForm())
+ const [editingId, setEditingId] = useState(null)
+ const [previewOpen, setPreviewOpen] = useState(false)
+ const [copyOpen, setCopyOpen] = useState(false)
+ const [applyBusy, setApplyBusy] = useState(false)
+ const applyLock = useRef(false)
+ const [pendingDelete, setPendingDelete] = useState<{ serverId: string; serverName: string; rule: FilterRule } | null>(null)
+ const [deletePending, setDeletePending] = useState(false)
+ const [deleteError, setDeleteError] = useState(null)
+ const deleteLock = useRef(false)
+ const [movePending, setMovePending] = useState(false)
+ const [moveError, setMoveError] = useState(null)
+ const moveLock = useRef(false)
+ const [liveStale, setLiveStale] = useState(false)
+ const readEpoch = useRef(0)
+ const [readyServerId, setReadyServerId] = useState(null)
+ const [rulesReading, setRulesReading] = useState(false)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [historyLoading, setHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+ const historyEpoch = useRef(0)
+ const [historyRestoring, setHistoryRestoring] = useState(false)
+ const [revisions, setRevisions] = useState([])
+
+ useEffect(() => {
+ if (!isLive) {
+ queueMicrotask(() => {
+ setLiveServers([])
+ setGreByServer({})
+ setRecRoutesByServer({})
+ setRulesets(buildRulesets(servers, serverFilterRulesets))
+ setSelectedServerId(servers[0]?.id ?? "")
+ setLiveLoadState("idle")
+ setLiveStale(false)
+ })
+ return
+ }
+
+ let cancelled = false
+ readEpoch.current += 1
+ setReadyServerId(null)
+ setLiveLoadState("loading")
+ setLiveLoadError(null)
+ Promise.all([
+ apiFetch("/api/servers"),
+ apiFetch("/api/filters/rules"),
+ ])
+ .then(([serverPayload, filtersPayload]) => {
+ if (cancelled) return
+ const serverResult = firewallServersSchema.safeParse(serverPayload)
+ const filtersResult = filtersResponseSchema.safeParse(filtersPayload)
+ if (!serverResult.success || !filtersResult.success) throw new Error("Сервер вернул некорректные данные Filters")
+ const backendServers = serverResult.data, liveFilters = filtersResult.data
+ if (liveFilters.stale) throw new Error("Актуальные правила недоступны")
+ if (backendServers.some(server => server.enabled && !liveFilters.rulesets.some(rs => rs.serverId === String(server.id)))) throw new Error("Сервер не вернул наборы правил всех устройств")
+ const mappedServers: Server[] = backendServers.map(s => ({
+ id: String(s.id),
+ name: s.name || s.host,
+ host: s.host,
+ model: "—",
+ os: "—",
+ site: s.site,
+ country: s.country || "UN",
+ asn: s.asn,
+ type: s.type,
+ enabled: s.enabled,
+ status: (s.status ?? "offline") as Server["status"],
+ latency: s.latency != null ? Math.round(s.latency) : null,
+ sessions: 0,
+ }))
+
+ const normalizedRulesets = buildRulesets(mappedServers, liveFilters.rulesets)
+ setLiveServers(mappedServers)
+ setRulesets(normalizedRulesets)
+ setSelectedServerId(prev => (
+ mappedServers.some(s => s.id === prev) ? prev : (mappedServers[0]?.id ?? "")
+ ))
+ setLiveLoadState("idle")
+ })
+ .catch(error => {
+ if (cancelled) return
+ setLiveLoadError(error instanceof Error ? error.message : "Не удалось загрузить Filters")
+ setLiveLoadState("error")
+ })
+ return () => { cancelled = true }
+ }, [isLive, apiFetch, loadAttempt])
+
+ const loadLiveRules = useCallback(async (serverId: string) => {
+ if (!isLive || !serverId || !mounted.current) return false
+ const epoch = ++readEpoch.current
+ setReadyServerId(null)
+ setRulesReading(true)
+ try {
+ const payload = await apiFetch(
+ `/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
+ )
+ if (epoch !== readEpoch.current) return false
+ const parsed = filtersResponseSchema.safeParse(payload)
+ if (!parsed.success) throw new Error("Сервер вернул некорректные правила Filters")
+ const fresh = parsed.data
+ const ruleset = fresh.rulesets.find(r => r.serverId === serverId)
+ if (!ruleset || fresh.stale || fresh.live !== true) throw new Error("Не удалось получить актуальные правила выбранного устройства")
+ const liveRules = ruleset.rules
+ setRulesets((prev) => {
+ const has = prev.some((rs) => rs.serverId === serverId)
+ if (!has) return [...prev, { serverId, rules: liveRules }]
+ return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
+ })
+ setLiveStale(false)
+ setReadyServerId(serverId)
+ setRulesReceipt(prev => ({ ...prev, [serverId]: { at: new Date().toISOString(), kind: "read" } }))
+ return true
+ } catch (err) {
+ if (epoch !== readEpoch.current) return false
+ setLiveStale(true)
+ toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
+ return false
+ } finally {
+ if (epoch === readEpoch.current) setRulesReading(false)
+ }
+ }, [isLive, apiFetch])
+
+ useEffect(() => {
+ if (!isLive || !selectedServerId || liveLoadState !== "idle") return
+ if (!liveServers.some((s) => s.id === selectedServerId)) return
+ void Promise.all([
+ loadLiveRules(selectedServerId),
+ ensureGreTunnels(selectedServerId),
+ ensureRecursiveRoutes(selectedServerId),
+ ])
+ }, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
+
+ const allServers = !isLive ? servers : liveServers
+ const allTunnels = !isLive ? greTunnels : (greByServer[selectedServerId] ?? [])
+ const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
+ const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
+ const mutationsLocked = (isLive && !can("mm:filters:write")) || movePending || (isLive && (applyBusy || historyRestoring || rulesReading || readyServerId !== selectedServerId || liveStale || liveLoadState !== "idle"))
+
+ const filterRailItems = useMemo(() => (
+ allServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
+ }))
+ ), [allServers, rulesets])
+
+ const handleSelectServer = useCallback((id: string) => {
+ if (applyLock.current) return
+ readEpoch.current += 1
+ setReadyServerId(null)
+ setRulesReading(false)
+ setLiveStale(false)
+ setSheetOpen(false)
+ setPendingDelete(null)
+ setDeleteError(null)
+ setMoveError(null)
+ setSelectedServerId(id)
+ setSearch("")
+ }, [])
+
+ const currentRules = useMemo(
+ () => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
+ [rulesets, selectedServerId],
+ )
+
+ const filteredRules = useMemo(() => {
+ const q = search.toLowerCase()
+ if (!q) return currentRules
+ return currentRules.filter((r) =>
+ r.community.includes(q) ||
+ (communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
+ r.gateway.includes(q) ||
+ r.description.toLowerCase().includes(q),
+ )
+ }, [currentRules, search, communityNameMap])
+
+ const applyRules = useCallback(async (
+ serverId: string,
+ nextRules: FilterRule[] | (() => Promise),
+ source: "apply" | "copy" = "apply",
+ ) => {
+ if (isLive && !can("mm:filters:write")) return { ok: false, error: "Нет прав на изменение фильтров" }
+ if (!mounted.current) return { ok: false, error: "Источник данных изменён" }
+ if (applyLock.current || applyBusy || historyRestoring) return { ok: false, error: "Дождитесь завершения текущей операции" }
+ if (isLive && (liveStale || liveLoadState !== "idle" || (serverId === selectedServerId && readyServerId !== serverId))) {
+ const error = "Роутер недоступен — изменения заблокированы"
+ toast.error(error)
+ return { ok: false, error }
+ }
+ if (!isLive) {
+ const rules = typeof nextRules === "function" ? await nextRules() : nextRules
+ setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules } : rs))
+ return { ok: true }
+ }
+ applyLock.current = true
+ readEpoch.current += 1
+ setRulesReading(false)
+ setApplyBusy(true)
+ try {
+ const rules = typeof nextRules === "function" ? await nextRules() : nextRules
+ if (!mounted.current) return { ok: false, error: "Источник данных изменён" }
+ const payload = await apiFetch("/api/filters/rules", {
+ method: "PUT",
+ body: JSON.stringify({ serverId, rules, source }),
+ })
+ if (!mounted.current) return { ok: false, error: "Источник данных изменён" }
+ const parsed = filtersMutationSchema.safeParse(payload)
+ if (!parsed.success) throw new Error("Сервер не подтвердил применение правил")
+ const res = parsed.data
+ setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: res.rules ?? rules } : rs))
+ setRulesReceipt(prev => ({ ...prev, [serverId]: { at: new Date().toISOString(), kind: "write" } }))
+ toast.success("Правила применены на роутер")
+ return { ok: true }
+ } catch (err) {
+ if (!mounted.current) return { ok: false, error: "Источник данных изменён" }
+ const error = err instanceof Error ? err.message : "Не удалось применить правила на роутер"
+ toast.error("Не удалось применить правила на роутер", { description: error })
+ return { ok: false, error }
+ } finally {
+ applyLock.current = false
+ setApplyBusy(false)
+ }
+ }, [isLive, apiFetch, applyBusy, historyRestoring, liveStale, liveLoadState, readyServerId, selectedServerId])
+
+ const refreshFromRouter = useCallback(async () => {
+ if (!isLive || !selectedServerId || !mounted.current || applyLock.current || applyBusy || historyRestoring) return
+ applyLock.current = true
+ setApplyBusy(true)
+ try {
+ await loadLiveRules(selectedServerId)
+ await Promise.all([
+ ensureGreTunnels(selectedServerId),
+ ensureRecursiveRoutes(selectedServerId),
+ ])
+ } finally {
+ applyLock.current = false
+ setApplyBusy(false)
+ }
+ }, [isLive, selectedServerId, applyBusy, historyRestoring, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
+
+ useEffect(() => () => { historyEpoch.current += 1; readEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
+
+ const loadRevisions = useCallback(async () => {
+ if (!isLive || !selectedServerId || !mounted.current) return
+ const epoch = ++historyEpoch.current
+ setHistoryLoading(true)
+ setHistoryError(null)
+ try {
+ const res = await apiFetch(
+ `/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
+ )
+ if (epoch !== historyEpoch.current) return
+ setRevisions(parseConfigRevisions(res, selectedServerId, "filters"))
+ } catch (error) {
+ if (epoch !== historyEpoch.current) return
+ setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
+ } finally {
+ if (epoch === historyEpoch.current) setHistoryLoading(false)
+ }
+ }, [isLive, selectedServerId, apiFetch])
+
+ const restoreRevision = useCallback(async (id: string) => {
+ if (!can("mm:filters:write")) throw new Error("Нет прав на изменение конфигурации")
+ if (!isLive || !selectedServerId) throw new Error("Выберите устройство в режиме живых данных")
+ if (applyLock.current || applyBusy || historyRestoring) throw new Error("Дождитесь завершения текущей операции")
+ applyLock.current = true
+ setApplyBusy(true)
+ setHistoryRestoring(true)
+ readEpoch.current += 1
+ setRulesReading(false)
+ try {
+ const payload = await apiFetch(
+ `/api/filters/revisions/${encodeURIComponent(id)}/restore`,
+ { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
+ )
+ if (!mounted.current) return
+ const parsed = filtersMutationSchema.safeParse(payload)
+ if (!parsed.success) throw new Error("Сервер не подтвердил восстановление")
+ const res = parsed.data
+ if (res.rules) {
+ setRulesReceipt(prev => ({ ...prev, [selectedServerId]: { at: new Date().toISOString(), kind: "write" } }))
+ setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
+ } else {
+ if (!await loadLiveRules(selectedServerId)) throw new Error("Версия применена, но актуальные правила не прочитаны — обновите данные")
+ }
+ if (!mounted.current) return
+ setLiveStale(false)
+ setReadyServerId(selectedServerId)
+ await loadRevisions()
+ if (!mounted.current) return
+ toast.success("Версия применена на роутер")
+ } finally {
+ applyLock.current = false
+ setApplyBusy(false)
+ setHistoryRestoring(false)
+ }
+ }, [isLive, selectedServerId, apiFetch, applyBusy, historyRestoring, loadLiveRules, loadRevisions])
+
+ const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
+ const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
+ return applyRules(serverId, updater(current))
+ }, [rulesets, applyRules])
+
+ const openCreate = () => {
+ if (mutationsLocked || applyLock.current) return
+ setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
+ }
+ const openEdit = (rule: FilterRule) => {
+ if (mutationsLocked || applyLock.current) return
+ // The observed catalog can have new DB IDs after refresh. Recover only an
+ // explicit recursive reference, never reinterpret a named GRE interface.
+ const routes = recRoutesByServer[selectedServerId] ?? []
+ const recursive = isRecursiveGatewayRef(rule.gatewayTunnelId)
+ ? routes.find(r => r.id === rule.gatewayTunnelId.slice(4)) ?? pickRecursiveRouteByGatewayHop(routes, rule.gateway)
+ : undefined
+ setSheetInitial({
+ action: rule.action ?? "route",
+ community: rule.community,
+ gateway: rule.gateway,
+ gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
+ gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
+ description: rule.description,
+ })
+ setSheetMode("edit"); setEditingId(rule.id); setSheetOpen(true)
+ }
+
+ const handleSave = async (form: RuleForm): Promise => {
+ if (mutationsLocked) return "Изменения заблокированы — обновите данные или дождитесь завершения операции"
+ if (isLive && form.action === "route" && !gatewayReady[selectedServerId]?.[form.gatewayKind]) return "Шлюзы устройства не подтверждены — обновите шлюзы"
+ const { gatewayKind: _gk, ...payload } = form
+ let result
+ if (sheetMode === "create") {
+ result = await updateRules(selectedServerId, rules => [
+ ...rules,
+ { id: newId(), ...payload, communityName: communityNameMap[payload.community] ?? "" },
+ ])
+ } else if (editingId) {
+ result = await updateRules(selectedServerId, rules =>
+ rules.map(r => r.id === editingId
+ ? { ...r, ...payload, communityName: communityNameMap[payload.community] ?? "" }
+ : r
+ )
+ )
+ }
+ if (!result?.ok) return result?.error ?? "Не удалось сохранить правило"
+ setSheetOpen(false)
+ return null
+ }
+
+ const handleDelete = (id: string) => {
+ if (mutationsLocked || applyLock.current) return
+ const rule = currentRules.find(r => r.id === id)
+ if (!rule || !selectedServer) return
+ setDeleteError(null)
+ setPendingDelete({ serverId: selectedServerId, serverName: selectedServer.name, rule })
+ }
+ const confirmDelete = async () => {
+ if (!pendingDelete || deleteLock.current) return
+ if (mutationsLocked || applyLock.current) {
+ setDeleteError("Дождитесь завершения операции или обновите данные")
+ return
+ }
+ const current = rulesets.find(rs => rs.serverId === pendingDelete.serverId)?.rules
+ if (!current?.some(r => r.id === pendingDelete.rule.id)) {
+ setDeleteError("Правило отсутствует в текущем списке — обновите данные")
+ return
+ }
+ deleteLock.current = true
+ setDeletePending(true)
+ setDeleteError(null)
+ try {
+ const result = await applyRules(pendingDelete.serverId, current.filter(r => r.id !== pendingDelete.rule.id))
+ if (!result.ok) setDeleteError(result.error ?? "Не удалось удалить правило")
+ else setPendingDelete(null)
+ } catch (error) {
+ setDeleteError(error instanceof Error ? error.message : "Не удалось удалить правило")
+ } finally {
+ deleteLock.current = false
+ setDeletePending(false)
+ }
+ }
+
+ const handleCopyRules = useCallback(async (targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
+ const prepare = async () => {
+ const payload = await apiFetch(`/api/filters/rules?serverId=${encodeURIComponent(targetServerId)}`)
+ if (!mounted.current) throw new Error("Источник данных изменён")
+ const parsed = filtersResponseSchema.safeParse(payload)
+ const target = parsed.success ? parsed.data.rulesets.find(rs => rs.serverId === targetServerId) : undefined
+ if (!parsed.success || !target || parsed.data.stale || parsed.data.live !== true) throw new Error("Не удалось получить актуальные правила целевого устройства")
+ setRulesReceipt(prev => ({ ...prev, [targetServerId]: { at: new Date().toISOString(), kind: "read" } }))
+ setRulesets(prev => prev.map(rs => rs.serverId === targetServerId ? { ...rs, rules: target.rules } : rs))
+ return mode === "replace" ? rules : [...target.rules, ...rules]
+ }
+ const existing = rulesets.find(rs => rs.serverId === targetServerId)?.rules ?? []
+ const localRules = mode === "replace" ? rules : [...existing, ...rules]
+ const result = await applyRules(targetServerId, isLive ? prepare : localRules, "copy")
+ return result.ok ? null : result.error ?? "Не удалось скопировать правила"
+ }, [rulesets, applyRules, apiFetch, isLive])
+ const handleMove = async (id: string, direction: -1 | 1) => {
+ if (mutationsLocked || applyLock.current || moveLock.current) return
+ const index = currentRules.findIndex(r => r.id === id)
+ const destination = index + direction
+ if (index < 0 || destination < 0 || destination >= currentRules.length) return
+ moveLock.current = true
+ setMovePending(true)
+ setMoveError(null)
+ const next = [...currentRules]
+ ;[next[index], next[destination]] = [next[destination], next[index]]
+ try {
+ const result = await applyRules(selectedServerId, next)
+ if (!result.ok) setMoveError(result.error ?? "Не удалось переместить правило")
+ } catch (error) {
+ setMoveError(error instanceof Error ? error.message : "Не удалось переместить правило")
+ } finally {
+ moveLock.current = false
+ setMovePending(false)
+ }
+ }
+
+ if (isLive && liveLoadState === "error" && liveServers.length === 0) {
+ return
+
Не удалось загрузить Filters: {liveLoadError}
+
setLoadAttempt(n => n + 1)}>Повторить загрузку
+
+ }
+
+ if (isLive && liveLoadState === "loading") {
+ return (
+
+
+
Загрузка правил и GRE из бекенда…
+
+ )
+ }
+
+ if (!selectedServer) {
+ return (
+
+ Нет доступных серверов
+
+ )
+ }
+
+ return (
+ <>
+
+
+ {isLive && (
+ <>
+ void refreshFromRouter()}
+ disabled={applyBusy || historyRestoring}
+ title="Прочитать актуальные правила с роутера"
+ >
+
+ Обновить
+
+ {
+ if (applyLock.current) return
+ setHistoryOpen(true)
+ void loadRevisions()
+ }}
+ disabled={applyBusy || historyRestoring}
+ title="История версий и откат на CHR"
+ >
+
+ История
+
+ >
+ )}
+ setPreviewOpen(true)}>
+ RouterOS
+
+ setCopyOpen(true)}
+ disabled={currentRules.length === 0 || mutationsLocked}
+ title="Копировать правила на другой сервер"
+ >
+ Копировать
+
+
+ Новое правило
+
+ >
+ }
+ />
+ }
+ banner={
+ <>
+ {isLive && !can("mm:filters:write") && Режим просмотра. Изменение фильтров недоступно.
}
+ {isLive && liveLoadState === "error" && (
+
+
+
Не удалось загрузить Filters: {liveLoadError}. Сохранены последние данные.
+
setLoadAttempt(n => n + 1)}>Повторить загрузку
+
+ )}
+ {isLive && Object.values(gatewayErrors[selectedServerId] ?? {}).some(Boolean) &&
+
{Object.values(gatewayErrors[selectedServerId] ?? {}).filter(Boolean).join(" · ")}. Сохранены последние каталоги шлюзов.
+
{ void ensureGreTunnels(selectedServerId); void ensureRecursiveRoutes(selectedServerId) }}>Обновить шлюзы
+
}
+ {isLive && rulesReading && Чтение актуальных правил устройства… Изменения временно заблокированы.
}
+ {isLive && liveStale && liveLoadState !== "error" && (
+
+
+ Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
+
+ )}
+
+
+
+ setSearch(e.target.value)} />
+ {search && (
+ setSearch("")}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
+
+
+ )}
+
+
+ Всего правил
+ {totalRules}
+
+ {(() => {
+ const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
+ if (bhTotal === 0) return null
+ return (
+
+ ⊘ {bhTotal} blackhole
+
+ )
+ })()}
+
+ {filteredRules.length !== currentRules.length
+ ? `${filteredRules.length} из ${currentRules.length} правил`
+ : `${currentRules.length} правил`
+ }
+
+
+ >
+ }
+ >
+
+
+ {/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
+ {mode === "mock" && (() => {
+ const filter = mockFiltersCatalog.find(f => f.servers.includes(selectedServerId))
+ const hasLarge = filter?.largeCommunities && filter.largeCommunities.length > 0
+ const hasRpki = filter?.rpkiValid
+ const hasFlow = filter?.flowspecRules && filter.flowspecRules.length > 0
+ if (!hasLarge && !hasRpki && !hasFlow) return null
+ return (
+
+ {hasRpki && (
+
+ RPKI · Только валидные маршруты
+
+ )}
+ {hasLarge && (
+
+ Large Communities RFC 8092 · {filter?.largeCommunities?.join(", ")}
+
+ )}
+ {hasFlow && (
+
+ Flowspec · {filter?.flowspecRules?.length} правил
+
+ )}
+
+ )
+ })()}
+
+
+
+ {/* selected server header */}
+
+
+
+
{selectedServer.name}
+
+
{selectedServer.host}
+
{selectedServer.asn}
+ {selectedServer.latency !== null && (
+
60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
+ )}>{selectedServer.latency}мс
+ )}
+
+ {applyBusy && Применение на роутер… }
+ {currentRules.length} правил
+
+
+
+ {isLive &&
+ {(["rules", "gre", "recursive"] as const).map(kind => {
+ const receipt = kind === "rules" ? rulesReceipt[selectedServerId]?.at : gatewayReceipt[selectedServerId]?.[kind]
+ const label = kind === "rules" ? rulesReceipt[selectedServerId]?.kind === "write" ? "Применение подтверждено API" : "Правила получены в приложении" : kind === "gre" ? "GRE получены в приложении" : "Рекурсивные шлюзы получены в приложении"
+ return
+ {label}: {receipt ? {new Date(receipt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })} : "неизвестно"}
+
+ })}
+
}
+
+ {movePending && Ожидание подтверждения порядка…
}
+ {moveError && {moveError}
}
+
+ {currentRules.length === 0 ? (
+ /* empty state */
+ }
+ title="Нет правил фильтрации"
+ description={
+ (isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
+ ? "Добавьте правило: BGP community → GRE-шлюз"
+ : "Сначала добавьте GRE-туннели для этого сервера"
+ }
+ action={
+ (isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
+
+ Добавить правило
+
+ ) : undefined
+ }
+ className="py-20"
+ />
+ ) : filteredRules.length === 0 ? (
+ /* no search results */
+
+ ) : (
+ { void handleMove(id, -1) }}
+ onMoveDown={(id) => { void handleMove(id, 1) }}
+ />
+ )}
+
+ {/* add rule shortcut */}
+
+
+ Добавить правило для {selectedServer.name}
+
+
+
+
+
+
+ {
+ if (!open && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) }
+ }}>
+
+
+ Удалить правило Filters?
+
+ Community {pendingDelete?.rule.community} · {pendingDelete?.serverName}.
+ Правило будет удалено из набора фильтров устройства.
+
+ {deletePending && Ожидание подтверждения удаления…
}
+ {deleteError && {deleteError}
}
+
+
+ Отмена
+ { e.preventDefault(); void confirmDelete() }}>Удалить
+
+
+
+
+ setSheetOpen(false)}
+ serversList={allServers}
+ tunnelsList={allTunnels}
+ communityNameMap={communityNameMap}
+ communitySuggestions={communitySuggestions}
+ evoHint={evoCommunityHint}
+ recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
+ />
+
+ setPreviewOpen(false)}
+ serversList={allServers}
+ tunnelsList={allTunnels}
+ recRoutesByServer={recRoutesByServer}
+ />
+
+ setCopyOpen(false)}
+ sourceServerId={selectedServerId}
+ sourceRules={currentRules}
+ rulesets={rulesets}
+ onCopy={handleCopyRules}
+ serversList={allServers}
+ greByServer={greByServer}
+ ensureGreFor={ensureGreTunnels}
+ recRoutesByServer={recRoutesByServer}
+ ensureRecursiveFor={ensureRecursiveRoutes}
+ catalogReady={isLive ? gatewayReady : undefined}
+ />
+
+ { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
+ loadError={historyError}
+ onRetry={() => { if (!applyLock.current) void loadRevisions() }}
+ title="История фильтров"
+ itemLabel="правил"
+ revisions={revisions}
+ loading={historyLoading}
+ restoring={historyRestoring}
+ restoreDisabled={!can("mm:filters:write") || !isLive || !allServers.some(s => s.id === selectedServerId && s.enabled) || applyBusy || historyRestoring || movePending}
+ onRestore={restoreRevision}
+ />
+ >
+ )
+}
+
+export default function FiltersSource() {
+ const { mode, backendUrl } = useDataSource()
+ return
+}
diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx
new file mode 100644
index 00000000..ce534a0c
--- /dev/null
+++ b/app/(main)/firewall/page.tsx
@@ -0,0 +1,2862 @@
+"use client"
+
+import { can } from "@/lib/auth"
+
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useSearchParams } from "next/navigation"
+import { PageHeader } from "@/components/page-header"
+import {
+ FirewallRulesDataGrid,
+ ActionBadge,
+ ChainBadge,
+} from "@/components/data-grids/firewall-rules-data-grid"
+import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
+import { DataPageCard } from "@/components/data-page-card"
+import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
+import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
+import {
+ firewallRules,
+ servers as mockServers,
+ type FirewallAddressListEntry,
+ type FirewallRule,
+ type FirewallTable,
+ type Server,
+} from "@/lib/data"
+import { useDataSource } from "@/lib/data-source"
+import { firewallResponseSchema, firewallServersSchema, mergeFirewallRows, type FirewallFailure, type FirewallBackendServer } from "@/lib/firewall-response"
+import { updateFirewallSliceTimes, firewallTimeRange, type FirewallSliceTime } from "@/lib/firewall-freshness"
+import { requestJson } from "@/shared/api/http-client"
+import { Frame, FramePanel } from "@/components/reui/frame"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from "@/components/ui/input-group"
+import {
+ Sheet, SheetContent, SheetHeader, SheetTitle,
+ SheetDescription, SheetFooter, SheetClose,
+} from "@/components/ui/sheet"
+import {
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
+ DropdownMenuItem, DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+import {
+ AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
+} from "@/components/ui/alert-dialog"
+import { cn } from "@/lib/utils"
+import {
+ PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon,
+ ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon,
+ PencilIcon, Trash2Icon, CodeXmlIcon,
+ MoreHorizontalIcon,
+ PowerIcon, CheckCircleIcon,
+ PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon,
+ CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon,
+ SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon,
+} from "lucide-react"
+import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
+import { toast } from "sonner"
+import { ConfigHistorySheet } from "@/components/config-history-sheet"
+import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type ChainGroup = "filter" | "nat" | "mangle" | "raw" | "address-lists" | "simulator"
+type ChainFilter = "all" | string
+
+// ─── Simulator types ──────────────────────────────────────────────────────────
+
+interface PacketDef {
+ chain: string
+ proto: string
+ srcAddr: string
+ dstAddr: string
+ srcPort: string
+ dstPort: string
+ inIface: string
+ outIface: string
+ connState: string
+ srcAddrList: string
+ dstAddrList: string
+ tlsHost: string
+}
+
+type SimSpeed = "slow" | "normal" | "fast" | "instant"
+
+interface SimCheck {
+ field: string
+ ruleVal: string
+ packetVal: string
+ passed: boolean
+}
+
+interface SimStep {
+ rule: FirewallRule
+ index: number
+ status: "pending" | "evaluating" | "match" | "skip" | "disabled" | "passthrough"
+ checks: SimCheck[]
+ failedAt?: string
+}
+
+// ─── Scenario types ───────────────────────────────────────────────────────────
+
+interface ScenarioRule {
+ id: string
+ chain: string
+ action: string
+ proto: string
+ src: string
+ dst: string
+ port: string
+ iface: string
+ comment: string
+ enabled: boolean
+}
+
+interface SimScenario {
+ id: string
+ name: string
+ description: string
+ packet: PacketDef
+ rules: ScenarioRule[]
+ createdAt: number
+}
+
+// ─── Packet presets ───────────────────────────────────────────────────────────
+
+const DEFAULT_PKT: PacketDef = {
+ chain: "forward", proto: "tcp",
+ srcAddr: "10.10.0.100", dstAddr: "142.250.74.110",
+ srcPort: "54321", dstPort: "443",
+ inIface: "lan", outIface: "wan-msk",
+ connState: "new", srcAddrList: "", dstAddrList: "", tlsHost: "",
+}
+
+// ─── RouterOS traversal paths ────────────────────────────────────────────────
+// Maps packet "flow type" (= packet.chain value) → ordered chain names to evaluate.
+// Mirrors the real RouterOS pipeline so Mangle, Raw, NAT rules are all included.
+
+const DIR_PATHS: Record = {
+ // Forwarded traffic (LAN → WAN, etc.)
+ forward: ["prerouting", "forward", "postrouting", "srcnat"],
+ // Traffic destined for the router itself
+ input: ["prerouting", "input"],
+ // Traffic originating from the router
+ output: ["output", "postrouting", "srcnat"],
+ // DNAT / port-forward
+ dstnat: ["prerouting", "dstnat", "forward", "postrouting", "srcnat"],
+}
+
+// Human-readable table hint shown next to each chain header in the trace
+const CHAIN_TABLE_HINT: Record = {
+ prerouting: "Raw + Mangle",
+ forward: "Mangle + Filter",
+ input: "Mangle + Filter",
+ output: "Mangle + Filter",
+ postrouting: "Mangle",
+ srcnat: "NAT",
+ dstnat: "NAT",
+}
+
+// ─── Packet presets ───────────────────────────────────────────────────────────
+
+const SIM_PRESETS: Array<{
+ id: string; label: string; desc: string; emoji: string; pkt: Partial
+}> = [
+ { id: "https", label: "HTTPS → интернет", desc: "TCP 443, forward, новый", emoji: "🌐",
+ pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", inIface: "lan", connState: "new" } },
+ { id: "http", label: "HTTP → интернет", desc: "TCP 80, forward", emoji: "📡",
+ pkt: { chain: "forward", proto: "tcp", dstPort: "80", srcAddr: "10.10.0.100", dstAddr: "93.184.216.34", inIface: "lan", connState: "new" } },
+ { id: "ssh-lan", label: "SSH управление", desc: "TCP 22, input, с LAN", emoji: "🔐",
+ pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } },
+ { id: "ssh-wan", label: "SSH с WAN", desc: "TCP 22, input, внешний", emoji: "🚨",
+ pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "185.220.101.45", dstAddr: "10.0.0.1", inIface: "wan-msk", connState: "new" } },
+ { id: "winbox", label: "WinBox", desc: "TCP 8291, input, с LAN", emoji: "🖥️",
+ pkt: { chain: "input", proto: "tcp", dstPort: "8291",srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } },
+ { id: "youtube", label: "YouTube bypass", desc: "TCP 443, dst-list youtube", emoji: "▶️",
+ pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", dstAddrList: "youtube-bypass", inIface: "lan", connState: "new" } },
+ { id: "cdn", label: "CDN mark-routing", desc: "TCP, dst-list cdn-bypass", emoji: "🚀",
+ pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "104.16.132.229", dstAddrList: "cdn-bypass", inIface: "lan", connState: "new" } },
+ { id: "nat-out", label: "Masquerade", desc: "srcnat, LAN → WAN", emoji: "🔄",
+ pkt: { chain: "srcnat", proto: "tcp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", dstPort: "443", outIface: "wan-msk" } },
+ { id: "icmp", label: "Ping ICMP", desc: "ICMP echo, forward", emoji: "📶",
+ pkt: { chain: "forward", proto: "icmp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", inIface: "lan" } },
+ { id: "established", label: "Established flow", desc: "TCP established, reverse", emoji: "↩️",
+ pkt: { chain: "forward", proto: "tcp", dstPort: "54321", srcAddr: "142.250.74.110", dstAddr: "10.10.0.100", inIface: "wan-msk", connState: "established" } },
+]
+
+// ─── Matching engine ──────────────────────────────────────────────────────────
+
+function _ipNum(ip: string): number {
+ return ip.split(".").reduce((a, o) => (a << 8) + parseInt(o, 10), 0) >>> 0
+}
+function _inCidr(ip: string, cidr: string): boolean {
+ if (!ip) return false
+ if (!cidr.includes("/")) return ip === cidr
+ const [net, b] = cidr.split("/"); const bits = parseInt(b)
+ if (bits === 0) return true
+ const mask = bits >= 32 ? 0xFFFFFFFF : (~(0xFFFFFFFF >>> bits)) >>> 0
+ return (_ipNum(ip) & mask) === (_ipNum(net) & mask)
+}
+function _mAddr(pAddr: string, rAddr: string): boolean {
+ if (!rAddr || rAddr === "—" || rAddr === "") return true
+ return rAddr.includes("/") ? _inCidr(pAddr, rAddr) : pAddr === rAddr
+}
+function _mPort(pPort: string, rPort: string): boolean {
+ if (!rPort || rPort === "—") return true
+ if (!pPort) return false
+ const n = parseInt(pPort)
+ for (const p of rPort.split(",")) {
+ const t = p.trim()
+ if (t.includes("-")) { const [lo, hi] = t.split("-").map(Number); if (n >= lo && n <= hi) return true }
+ else if (Number(t) === n) return true
+ }
+ return false
+}
+function _mList(pList: string, rList: string): boolean {
+ return !rList || rList === "—" || pList === rList
+}
+function _mIface(pI: string, rI: string): boolean {
+ return !rI || rI === "—" || !pI || pI === rI || rI === "*"
+}
+
+function evalRule(rule: FirewallRule, pkt: PacketDef): {
+ verdict: SimStep["status"]; checks: SimCheck[]; failedAt?: string
+} {
+ if (!rule.enabled) return { verdict: "disabled", checks: [] }
+
+ const checks: SimCheck[] = []
+ const fail = (c: SimCheck) => { checks.push(c); return { verdict: "skip" as const, checks, failedAt: c.field } }
+
+ if (rule.chain !== pkt.chain) return fail({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: false })
+ checks.push({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: true })
+
+ if (rule.proto && rule.proto !== "all") {
+ const ok = pkt.proto === rule.proto
+ if (!ok) return fail({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: false })
+ checks.push({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: true })
+ }
+
+ if (rule.src && rule.src !== "—") {
+ const ok = _mAddr(pkt.srcAddr, rule.src) || _mList(pkt.srcAddrList, rule.src)
+ if (!ok) return fail({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || "—", passed: false })
+ checks.push({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || pkt.srcAddrList, passed: true })
+ }
+
+ if (rule.dst && rule.dst !== "—") {
+ const ok = _mAddr(pkt.dstAddr, rule.dst) || _mList(pkt.dstAddrList, rule.dst)
+ if (!ok) return fail({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || "—", passed: false })
+ checks.push({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || pkt.dstAddrList, passed: true })
+ }
+
+ if (rule.port && rule.port !== "—") {
+ const ok = _mPort(pkt.dstPort, rule.port)
+ if (!ok) return fail({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort || "—", passed: false })
+ checks.push({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort, passed: true })
+ }
+
+ if (rule.iface && rule.iface !== "—") {
+ const ok = _mIface(pkt.inIface, rule.iface)
+ if (!ok) return fail({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface || "—", passed: false })
+ checks.push({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface, passed: true })
+ }
+
+ if (rule.tlsHost && pkt.tlsHost) {
+ const pat = "^" + rule.tlsHost.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$"
+ const ok = new RegExp(pat).test(pkt.tlsHost)
+ if (!ok) return fail({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: false })
+ checks.push({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: true })
+ }
+
+ return { verdict: rule.action === "passthrough" ? "passthrough" : "match", checks }
+}
+
+type AddressListEntry = FirewallAddressListEntry
+
+// ─── Mock address-list data ───────────────────────────────────────────────────
+
+const INIT_ADDRESS_LISTS: AddressListEntry[] = [
+ { id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*1" },
+ { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false, family: "ip", serverId: "srv1", rosId: "*2" },
+ { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*3" },
+ { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false, family: "ip", serverId: "srv1", rosId: "*4" },
+ { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false, family: "ip", serverId: "srv1", rosId: "*5" },
+ { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false, family: "ip", serverId: "srv1", rosId: "*6" },
+ { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false, family: "ip", serverId: "srv1", rosId: "*7" },
+ { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*8" },
+ { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false, family: "ip", serverId: "srv1", rosId: "*9" },
+ { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false, family: "ip", serverId: "srv1", rosId: "*10" },
+ { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*11" },
+ { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false, family: "ip", serverId: "srv1", rosId: "*12" },
+ { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00", family: "ip", serverId: "srv1", rosId: "*13" },
+ { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18", family: "ip", serverId: "srv1", rosId: "*14" },
+ { id: "al15", list: "v6-allow", address: "2a01:4f8::/32", comment: "Hetzner v6", disabled: false, family: "ip6", serverId: "srv1", rosId: "*1" },
+]
+
+// ─── IP family filter ─────────────────────────────────────────────────────────
+
+type IpFamily = "ip" | "ip6"
+
+const IP_FAMILY_LABELS: Record = {
+ ip: "IPv4",
+ ip6: "IPv6",
+}
+
+// ─── Chain groups config ─────────────────────────────────────────────────────
+
+const CHAIN_GROUPS: {
+ id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]
+}[] = [
+ {
+ id: "filter", label: "Filter", icon: ,
+ chains: ["input","forward","output"],
+ },
+ { id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] },
+ { id: "mangle", label: "Mangle", icon: , chains: ["prerouting","postrouting","forward","input","output"] },
+ { id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] },
+ { id: "address-lists", label: "Адр. листы", icon: , chains: [] },
+ { id: "simulator", label: "Симулятор", icon: , chains: [] },
+]
+
+// ─── Default form ─────────────────────────────────────────────────────────────
+
+const defaultForm = {
+ chain: "forward", action: "accept", proto: "all",
+ src: "", dst: "", srcPort: "", dstPort: "", iface: "",
+ inIface: "", outIface: "", connState: "",
+ srcAddrList: "", dstAddrList: "",
+ comment: "", enabled: true,
+ log: false, logPrefix: "",
+}
+
+type RuleForm = typeof defaultForm
+
+function looksLikeAddress(value: string): boolean {
+ const v = value.trim()
+ if (!v || v === "—") return false
+ return v.includes(".") || v.includes(":") || v.includes("/")
+}
+
+function tableOfGroup(group: ChainGroup): FirewallTable | null {
+ if (group === "filter" || group === "nat" || group === "mangle" || group === "raw") return group
+ return null
+}
+
+interface RuleWritePayload {
+ chain: string
+ action: string
+ protocol?: string
+ srcAddress?: string
+ dstAddress?: string
+ srcAddressList?: string
+ dstAddressList?: string
+ srcPort?: string
+ dstPort?: string
+ inInterface?: string
+ outInterface?: string
+ connectionState?: string
+ comment?: string
+ disabled?: boolean
+ log?: boolean
+ logPrefix?: string
+}
+
+function formToWritePayload(form: RuleForm): RuleWritePayload {
+ const src = form.srcAddrList.trim()
+ const dst = form.dstAddrList.trim()
+ return {
+ chain: form.chain,
+ action: form.action,
+ protocol: form.proto,
+ srcAddress: src && looksLikeAddress(src) ? src : undefined,
+ srcAddressList: src && !looksLikeAddress(src) ? src : undefined,
+ dstAddress: dst && looksLikeAddress(dst) ? dst : undefined,
+ dstAddressList: dst && !looksLikeAddress(dst) ? dst : undefined,
+ srcPort: form.srcPort.trim() || undefined,
+ dstPort: form.dstPort.trim() || undefined,
+ inInterface: form.inIface.trim() || undefined,
+ outInterface: form.outIface.trim() || undefined,
+ connectionState: form.connState.trim() || undefined,
+ comment: form.comment.trim() || undefined,
+ disabled: !form.enabled,
+ log: form.log,
+ logPrefix: form.log && form.logPrefix.trim() ? form.logPrefix.trim() : undefined,
+ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function fmtHits(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к`
+ return String(n)
+}
+
+// ActionBadge, ChainBadge — из firewall-rules-data-grid
+
+function NativeSelect({ value, onChange, children, className }: {
+ value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
+}) {
+ return (
+ onChange(e.target.value)}
+ className={cn(
+ "h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none",
+ "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
+ className,
+ )}>
+ {children}
+
+ )
+}
+
+// ─── RSC Export generator ─────────────────────────────────────────────────────
+
+function generateRsc(rules: FirewallRule[], timestamp?: string): string {
+ const lines: string[] = []
+ lines.push(`# MikrotikManager Firewall Export`)
+ if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
+ lines.push(`# Правил: ${rules.length}`)
+ lines.push("")
+
+ const byChain: Record = {}
+ for (const r of rules) {
+ if (!byChain[r.chain]) byChain[r.chain] = []
+ byChain[r.chain].push(r)
+ }
+
+ const TABLE_MAP: Record = {
+ input: "filter", forward: "filter", output: "filter",
+ srcnat: "nat", dstnat: "nat",
+ prerouting: "mangle", postrouting: "mangle",
+ }
+
+ for (const [chain, chainRules] of Object.entries(byChain)) {
+ const table = chainRules[0]?.table ?? TABLE_MAP[chain] ?? "filter"
+ const familyPath = chainRules[0]?.family === "ip6" ? "/ipv6 firewall" : "/ip firewall"
+ lines.push(`# ── ${familyPath} ${table} chain=${chain} ──────────────────────────────`)
+ for (const r of chainRules) {
+ const parts = [`${familyPath} ${table} add`]
+ parts.push(`chain=${r.chain}`)
+ parts.push(`action=${r.action}`)
+ if (r.proto && r.proto !== "all") parts.push(`protocol=${r.proto}`)
+ if (r.src && r.src !== "—") parts.push(`src-address-list=${r.src}`)
+ if (r.dst && r.dst !== "—") parts.push(`dst-address-list=${r.dst}`)
+ if (r.port && r.port !== "—") parts.push(`dst-port=${r.port}`)
+ if (r.iface && r.iface !== "—") parts.push(`in-interface=${r.iface}`)
+ if (r.comment) parts.push(`comment="${r.comment}"`)
+ if (!r.enabled) parts.push(`disabled=yes`)
+ lines.push(parts.join(" \\\n "))
+ lines.push("")
+ }
+ }
+
+ return lines.join("\n")
+}
+
+// ─── Rule Sheet ───────────────────────────────────────────────────────────────
+
+function RuleSheet({ open, onClose, initialRule, chainGroup, family, onSave, saving = false, error, returnFocus }: {
+ open: boolean
+ onClose: () => void
+ initialRule: Partial | null
+ chainGroup: ChainGroup
+ family: IpFamily
+ onSave: (payload: RuleWritePayload) => void
+ saving?: boolean
+ error?: string | null
+ returnFocus?: () => HTMLElement | null
+}) {
+ const isNew = !initialRule?.id
+ const [form, setForm] = useState(defaultForm)
+ const [formContext, setFormContext] = useState({ open: false, initialRule, chainGroup })
+
+ // Adjust this component before committing children with a stale draft.
+ if (formContext.open !== open || formContext.initialRule !== initialRule || formContext.chainGroup !== chainGroup) {
+ setFormContext({ open, initialRule, chainGroup })
+ if (open) {
+ setForm({
+ ...defaultForm,
+ chain: initialRule?.chain ?? (CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward"),
+ action: (initialRule?.action as string) ?? defaultForm.action,
+ proto: initialRule?.proto ?? defaultForm.proto,
+ comment: initialRule?.comment ?? "",
+ enabled: initialRule?.enabled ?? true,
+ srcAddrList: initialRule?.src && initialRule.src !== "—" ? initialRule.src : "",
+ dstAddrList: initialRule?.dst && initialRule.dst !== "—" ? initialRule.dst : "",
+ dstPort: initialRule?.port && initialRule.port !== "—" ? initialRule.port : "",
+ inIface: initialRule?.iface && initialRule.iface !== "—" ? initialRule.iface : "",
+ log: initialRule?.log ?? false,
+ logPrefix: initialRule?.logPrefix ?? "",
+ })
+ }
+ }
+
+ const set = (k: K, v: RuleForm[K]) =>
+ setForm((f) => ({ ...f, [k]: v }))
+
+ const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"]
+ const tableName = tableOfGroup(chainGroup) ?? "filter"
+ const familyPath = family === "ip6" ? "/ipv6 firewall" : "/ip firewall"
+ const showNatActions = chainGroup === "nat"
+ const actions = showNatActions
+ ? ["masquerade", "dst-nat", "src-nat", "netmap", "same", "passthrough"]
+ : chainGroup === "mangle"
+ ? ["mark-routing", "mark-conn", "mark-packet", "strip-ipv4-options", "passthrough", "add-src-to-address-list", "add-dst-to-address-list"]
+ : ["accept", "drop", "reject", "fasttrack-connection", "add-src-to-address-list", "add-dst-to-address-list", "passthrough"]
+
+ return (
+ { if (!v && !saving) onClose() }}>
+
+
+ {isNew ? "Новое правило" : "Редактировать правило"}
+
+ {familyPath} {tableName} · {IP_FAMILY_LABELS[family]} · RouterOS 7
+
+
+
+
+
+ {/* Chain + Action */}
+
+
Цепочка и действие
+
+
+ set("chain", v)}>
+ {chainsForGroup.map((c) => {c} )}
+
+
+
+ set("action", v)}>
+ {actions.map((a) => {a} )}
+
+
+
+
+
+ {/* Matching */}
+
+
Условие совпадения
+
+ set("proto", v)}>
+ {["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
+ {p}
+ )}
+
+
+
+
+
+ set("srcAddrList", e.target.value)} />
+
+
+ set("dstAddrList", e.target.value)} />
+
+
+
+
+
+ set("srcPort", e.target.value)} />
+
+
+ set("dstPort", e.target.value)} />
+
+
+
+
+
+ set("inIface", e.target.value)} />
+
+
+ set("outIface", e.target.value)} />
+
+
+
+
+ set("connState", e.target.value)} />
+
+
+
+ {/* Log + Comment */}
+
+
Логирование и комментарий
+
+
+
Log
+
Записывать совпадения в системный лог
+
+
set("log", v)} />
+
+ {form.log && (
+
+ set("logPrefix", e.target.value)} />
+
+ )}
+
+ set("comment", e.target.value)} />
+
+
+
+ {/* Enabled */}
+
+
+
Правило включено
+
Отключённые правила сохраняются, но не применяются
+
+
set("enabled", v)} />
+
+
+ {/* CLI preview */}
+
+
RouterOS CLI preview
+
+ {[
+ `${familyPath} ${tableName} add \\`,
+ ` chain=${form.chain} \\`,
+ ` action=${form.action}`,
+ form.proto !== "all" ? ` protocol=${form.proto} \\` : null,
+ form.srcAddrList
+ ? looksLikeAddress(form.srcAddrList)
+ ? ` src-address=${form.srcAddrList} \\`
+ : ` src-address-list=${form.srcAddrList} \\`
+ : null,
+ form.dstAddrList
+ ? looksLikeAddress(form.dstAddrList)
+ ? ` dst-address=${form.dstAddrList} \\`
+ : ` dst-address-list=${form.dstAddrList} \\`
+ : null,
+ form.dstPort ? ` dst-port=${form.dstPort} \\` : null,
+ form.inIface ? ` in-interface=${form.inIface} \\` : null,
+ form.outIface ? ` out-interface=${form.outIface} \\` : null,
+ form.connState ? ` connection-state=${form.connState} \\` : null,
+ form.log ? ` log=yes \\` : null,
+ form.log && form.logPrefix ? ` log-prefix="${form.logPrefix}" \\` : null,
+ form.comment ? ` comment="${form.comment}"` : null,
+ !form.enabled ? ` disabled=yes` : null,
+ ].filter(Boolean).join("\n")}
+
+
+
+ {saving && Ожидание результата сохранения…
}
+ {error && {error}
}
+
+
+ }>Отмена
+ onSave(formToWritePayload(form))}
+ >
+ {saving ? "Сохранение…" : isNew ? "Создать правило" : "Сохранить"}
+
+
+
+
+ )
+}
+
+// ─── Export Sheet ─────────────────────────────────────────────────────────────
+
+function ExportSheet({ open, onClose, rules }: {
+ open: boolean; onClose: () => void; rules: FirewallRule[]
+}) {
+ const timestamp = useMemo(
+ () => open ? new Date().toLocaleString("ru") : undefined,
+ [open],
+ )
+
+ const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp])
+
+ return (
+
+ )
+}
+
+// ─── Address List tab ─────────────────────────────────────────────────────────
+
+function AddressListsTab({
+ entries,
+ onAdd,
+ onAddToList,
+ onToggle,
+ onEdit,
+ onDelete,
+ showServer = false,
+ mutationsDisabled = false,
+}: {
+ entries: AddressListEntry[]
+ onAdd: () => void
+ onAddToList: (list: string) => void
+ onToggle: (entry: AddressListEntry) => void
+ onEdit: (entry: AddressListEntry) => void
+ onDelete: (entry: AddressListEntry) => void
+ showServer?: boolean
+ mutationsDisabled?: boolean
+}) {
+ const [search, setSearch] = useState("")
+ const [listFilter, setListFilter] = useState("all")
+
+ const lists = useMemo(() => {
+ const s = new Set(entries.map((e) => e.list))
+ return ["all", ...Array.from(s)]
+ }, [entries])
+
+ if (listFilter !== "all" && !lists.includes(listFilter)) setListFilter("all")
+
+ const filtered = useMemo(() => {
+ return entries.filter((e) => {
+ if (listFilter !== "all" && e.list !== listFilter) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q)
+ || (e.serverName ?? "").toLowerCase().includes(q)
+ })
+ }, [entries, search, listFilter])
+
+ const grouped = useMemo(() => {
+ if (listFilter !== "all") return { [listFilter]: filtered }
+ const g: Record = {}
+ for (const e of filtered) {
+ if (!g[e.list]) g[e.list] = []
+ g[e.list].push(e)
+ }
+ return g
+ }, [filtered, listFilter])
+
+ return (
+
+
+
+
+ setSearch(e.target.value)} />
+
+
+ {lists.map((l) => (
+ setListFilter(l)}
+ className={cn("px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
+ listFilter === l ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
+ )}>
+ {l === "all" ? "Все листы" : l}
+
+ ))}
+
+
{filtered.length} записей
+
Добавить
+
+
+
+ {Object.entries(grouped).map(([listName, listEntries]) => (
+
+
+
+
+
+ {listName}
+ ({listEntries.length})
+
+
onAddToList(listName)}>
+ Адрес
+
+
+
+ {listEntries.map((e) => (
+
+
+
{e.address}
+
{e.comment}
+ {showServer && e.serverName && (
+
{e.serverName}
+ )}
+ {e.timeout && (
+
+ ⏱ {e.timeout}
+
+ )}
+
onToggle(e)} />
+
+
+
+
+ } />
+
+ onEdit(e)}> Редактировать
+ onToggle(e)}>
+ {e.disabled ? "Включить" : "Отключить"}
+
+
+ onDelete(e)}>
+ Удалить
+
+
+
+
+ ))}
+
+
+
+ ))}
+
+
+ )
+}
+
+const defaultAddrForm = {
+ list: "",
+ address: "",
+ comment: "",
+ timeout: "",
+ enabled: true,
+}
+
+function AddressListSheet({
+ open, onClose, initial, family, onSave, saving = false, error, returnFocus,
+}: {
+ open: boolean
+ onClose: () => void
+ initial: Partial | null
+ family: IpFamily
+ onSave: (payload: { list: string; address: string; comment?: string; timeout?: string; disabled?: boolean }) => void
+ saving?: boolean
+ error?: string | null
+ returnFocus?: () => HTMLElement | null
+}) {
+ const isNew = !initial?.id
+ const [form, setForm] = useState(defaultAddrForm)
+ const [formContext, setFormContext] = useState({ open: false, initial })
+
+ if (formContext.open !== open || formContext.initial !== initial) {
+ setFormContext({ open, initial })
+ if (open) {
+ setForm({
+ list: initial?.list ?? "",
+ address: initial?.address ?? "",
+ comment: initial?.comment ?? "",
+ timeout: initial?.timeout ?? "",
+ enabled: !(initial?.disabled ?? false),
+ })
+ }
+ }
+
+ return (
+ { if (!v && !saving) onClose() }}>
+
+
+ {isNew ? "Запись address-list" : "Редактировать запись"}
+
+ {family === "ip6" ? "/ipv6 firewall address-list" : "/ip firewall address-list"} · {IP_FAMILY_LABELS[family]}
+
+
+
+
+ setForm((f) => ({ ...f, list: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, address: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, comment: e.target.value }))} />
+
+
+ setForm((f) => ({ ...f, timeout: e.target.value }))} />
+
+
+
+
Запись включена
+
Отключённые адреса не матчятся
+
+
setForm((f) => ({ ...f, enabled: v }))} />
+
+
+ {saving && Ожидание результата сохранения…
}
+ {error && {error}
}
+
+ }>Отмена
+ onSave({
+ list: form.list.trim(),
+ address: form.address.trim(),
+ comment: form.comment.trim() || undefined,
+ timeout: form.timeout.trim() || undefined,
+ disabled: !form.enabled,
+ })}
+ >
+ {saving ? "Сохранение…" : isNew ? "Добавить" : "Сохранить"}
+
+
+
+
+ )
+}
+
+// ─── Verdict helpers ──────────────────────────────────────────────────────────
+
+function getVerdictIcon(action: string) {
+ if (action === "drop" || action === "reject") return
+ if (action === "fasttrack-connection") return
+ if (action.startsWith("mark-")) return
+ return
+}
+
+function getVerdictColors(action: string) {
+ if (action === "drop" || action === "reject")
+ return { bg: "bg-red-500/10", border: "border-red-500/30", text: "text-red-600 dark:text-red-400" }
+ if (action === "fasttrack-connection")
+ return { bg: "bg-violet-500/10", border: "border-violet-500/30", text: "text-violet-600 dark:text-violet-400" }
+ if (action === "masquerade" || action.endsWith("-nat"))
+ return { bg: "bg-blue-500/10", border: "border-blue-500/30", text: "text-blue-600 dark:text-blue-400" }
+ if (action.startsWith("mark-"))
+ return { bg: "bg-amber-500/10", border: "border-amber-500/30", text: "text-amber-600 dark:text-amber-400" }
+ return { bg: "bg-emerald-500/10", border: "border-emerald-500/30", text: "text-emerald-600 dark:text-emerald-400" }
+}
+
+// ─── SimStepRow ───────────────────────────────────────────────────────────────
+
+function SimStepRow({ step, index }: { step: SimStep; index: number }) {
+ const [manualExpanded, setExpanded] = useState(false)
+ // Auto-expand on match/evaluating; user can also toggle manually
+ const expanded = manualExpanded || step.status === "match" || step.status === "evaluating"
+
+ const isClickable = step.checks.length > 0
+
+ return (
+
+
isClickable && setExpanded(e => !e)}
+ className={cn(
+ "w-full min-h-11 flex flex-wrap sm:flex-nowrap items-center gap-3 px-5 py-2.5 text-left transition-colors",
+ isClickable ? "hover:bg-muted/30 cursor-pointer" : "cursor-default",
+ step.status === "pending" && "opacity-40",
+ step.status === "disabled" && "opacity-25",
+ )}
+ >
+
+ {index + 1}
+
+
+
+
+ {step.rule.src || "any"} → {step.rule.dst || "any"}
+ {step.rule.proto && step.rule.proto !== "all" && (
+ {step.rule.proto}
+ )}
+ {step.rule.port && step.rule.port !== "—" && (
+ :{step.rule.port}
+ )}
+
+ {step.rule.comment && (
+
+ {step.rule.comment}
+
+ )}
+
+ {/* Status badge */}
+
+ {step.status === "pending" && (
+ ожидание
+ )}
+ {step.status === "evaluating" && (
+
+ проверяется…
+
+ )}
+ {step.status === "match" && (
+
+ совпадение!
+
+ )}
+ {step.status === "passthrough" && (
+
+ passthrough
+
+ )}
+ {step.status === "disabled" && (
+ ⊝ выключено
+ )}
+ {step.status === "skip" && (
+
+ пропуск
+ {step.failedAt && ({step.failedAt}) }
+
+ )}
+
+
+
+ {/* Expanded checks */}
+ {expanded && step.checks.length > 0 && (
+
+ {step.checks.map((c, ci) => (
+
+ {c.passed
+ ?
+ : }
+ {c.field}:
+ {c.ruleVal}
+ {!c.passed && (
+ <>
+ ≠
+ {c.packetVal}
+ >
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Scenario constants + helpers ────────────────────────────────────────────
+
+const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios"
+
+const DEFAULT_SRULE: Omit = {
+ chain: "forward", action: "accept", proto: "all",
+ src: "", dst: "", port: "", iface: "", comment: "", enabled: true,
+}
+
+function scenarioRuleToFw(r: ScenarioRule): FirewallRule {
+ return { ...r, hits: 0, action: r.action as FirewallRule["action"] }
+}
+
+// ─── Scenario Sheet ───────────────────────────────────────────────────────────
+
+function ScenarioSheet({ open, onClose, initial, onSave }: {
+ open: boolean
+ onClose: () => void
+ initial: SimScenario | null
+ onSave: (s: SimScenario) => void
+}) {
+ const isNew = !initial
+ const [name, setName] = useState(initial?.name ?? "")
+ const [desc, setDesc] = useState(initial?.description ?? "")
+ const [pkt, setPkt2] = useState(initial?.packet ?? DEFAULT_PKT)
+ const [rules, setRules] = useState(initial?.rules ?? [])
+ const [addOpen, setAddOpen]= useState(false)
+ const [addForm, setAddForm]= useState>(DEFAULT_SRULE)
+
+ // Reset form fields each time the sheet opens — standard modal initialization pattern
+
+ useEffect(() => {
+ if (!open) return
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setName(initial?.name ?? ""); setDesc(initial?.description ?? "")
+
+ setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? [])
+
+ setAddForm(DEFAULT_SRULE); setAddOpen(false)
+ }, [open, initial])
+
+ const setP = (k: K, v: PacketDef[K]) =>
+ setPkt2(p => ({ ...p, [k]: v }))
+ const setAF = >(k: K, v: Omit[K]) =>
+ setAddForm(f => ({ ...f, [k]: v }))
+
+ function addRule() {
+ setRules(prev => [...prev, { ...addForm, id: `sr-${Date.now()}` }])
+ setAddForm(DEFAULT_SRULE)
+ setAddOpen(false)
+ }
+ function removeRule(id: string) { setRules(prev => prev.filter(r => r.id !== id)) }
+ function toggleEnabled(id: string) {
+ setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
+ }
+ function moveRule(id: string, dir: -1 | 1) {
+ setRules(prev => {
+ const idx = prev.findIndex(r => r.id === id)
+ const swap = idx + dir
+ if (idx < 0 || swap < 0 || swap >= prev.length) return prev
+ const next = [...prev];
+ [next[idx], next[swap]] = [next[swap], next[idx]]
+ return next
+ })
+ }
+ function handleSave() {
+ if (!name.trim()) return
+ onSave({
+ id: initial?.id ?? `scenario-${Date.now()}`,
+ name: name.trim(), description: desc.trim(),
+ packet: pkt, rules,
+ createdAt: initial?.createdAt ?? Date.now(),
+ })
+ onClose()
+ }
+
+ const CHAIN_OPTS = ["forward","input","output","dstnat","srcnat","prerouting","postrouting"]
+ const PROTO_OPTS = ["all","tcp","udp","icmp","gre","esp"]
+ const ACTION_OPTS = [
+ "accept","drop","reject","mark-routing","mark-conn","masquerade",
+ "fasttrack-connection","passthrough","add-src-to-address-list","add-dst-to-address-list",
+ ]
+
+ return (
+ { if (!v) onClose() }}>
+
+
+ {isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`}
+ Набор правил firewall/mangle/nat + тестовый пакет
+
+
+
+
+ {/* Meta */}
+
+
+ {/* Default packet */}
+
+
Тестовый пакет по умолчанию
+
+
+ setP("chain", v)}>
+
+ forward — транзит
+ input — на роутер
+ output — от роутера
+ dstnat — port-forward
+
+
+ {["srcnat","prerouting","postrouting","ip6-forward","ip6-input","ip6-output"]
+ .map(c => {c} )}
+
+
+
+
+ setP("proto", v)}>
+ {PROTO_OPTS.map(p => {p} )}
+
+
+
+ setP("connState", e.target.value)} />
+
+
+ setP("srcAddr", e.target.value)} />
+
+
+ setP("dstAddr", e.target.value)} />
+
+
+ setP("dstPort", e.target.value)} />
+
+
+ setP("inIface", e.target.value)} />
+
+
+ setP("outIface", e.target.value)} />
+
+
+ setP("dstAddrList", e.target.value)} />
+
+
+
+
+ {/* Rules */}
+
+
+
+ Правила сценария
+
+
({rules.length})
+
+
setAddOpen(o => !o)}>
+
+ {addOpen ? "Отмена" : "Добавить правило"}
+
+
+
+ {/* Add-rule form */}
+ {addOpen && (
+
+
Новое правило
+
+
+ Цепочка
+ setAF("chain", v)}>
+ {CHAIN_OPTS.map(c => {c} )}
+
+
+
+ Действие
+ setAF("action", v)}>
+ {ACTION_OPTS.map(a => {a} )}
+
+
+
+ Протокол
+ setAF("proto", v)}>
+ {PROTO_OPTS.map(p => {p} )}
+
+
+
+ Dst Port
+ setAF("port", e.target.value)} />
+
+
+ Src address / list
+ setAF("src", e.target.value)} />
+
+
+ Dst address / list
+ setAF("dst", e.target.value)} />
+
+
+ In-interface
+ setAF("iface", e.target.value)} />
+
+
+ Комментарий
+ setAF("comment", e.target.value)} />
+
+
+
+
+ setAF("enabled", v)} />
+ Включено
+
+
Добавить
+
+
+ )}
+
+ {/* Rules table */}
+ {rules.length > 0 ? (
+
moveRule(id, -1)}
+ onMoveDown={(id) => moveRule(id, 1)}
+ onRemove={removeRule}
+ />
+ ) : (
+ !addOpen && (
+
+ Нет правил — нажмите «Добавить правило»
+
+ )
+ )}
+
+
+
+
+
+ }>Отмена
+
+ {isNew ? "Создать сценарий" : "Сохранить изменения"}
+
+
+
+
+ )
+}
+
+// ─── Simulator Tab ────────────────────────────────────────────────────────────
+
+const SIM_SPEED_MS: Record = { slow: 700, normal: 350, fast: 120, instant: 0 }
+const SIM_SPEED_LABELS: Record = {
+ slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно",
+}
+
+function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
+ // ── Core sim state ──────────────────────────────────────────────────────────
+ const [packet, setPacket] = useState(DEFAULT_PKT)
+ const [activePreset, setPreset] = useState("https")
+ const [simState, setSimState] = useState<"idle" | "running" | "done">("idle")
+ const [steps, setSteps] = useState([])
+ const [tracePage, setTracePage] = useState(0)
+ const [speed, setSpeed] = useState("normal")
+ const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null)
+ const cancelRef = useRef(false)
+ const runEpoch = useRef(0)
+ useEffect(() => () => { runEpoch.current += 1; cancelRef.current = true }, [])
+
+ // ── Scenarios state ─────────────────────────────────────────────────────────
+ const [scenarios, setScenarios] = useState(() => {
+ if (typeof window === "undefined") return []
+ try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") }
+ catch { return [] }
+ })
+ const [activeScenarioId, setActiveScenarioId] = useState(null)
+ const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false)
+ const [editingScenario, setEditingScenario] = useState(null)
+
+ useEffect(() => {
+ localStorage.setItem(SCENARIO_STORAGE_KEY, JSON.stringify(scenarios))
+ }, [scenarios])
+
+ const activeScenario = scenarios.find(s => s.id === activeScenarioId) ?? null
+
+ // Live rules vs scenario rules
+ const effectiveRules: FirewallRule[] = useMemo(
+ () => activeScenario ? activeScenario.rules.map(scenarioRuleToFw) : allRules,
+ [activeScenario, allRules],
+ )
+
+ const setPkt = (k: K, v: PacketDef[K]) =>
+ setPacket(p => ({ ...p, [k]: v }))
+
+ // All rules in traversal order across every chain the packet touches
+ const simRules = useMemo(() => {
+ const path = DIR_PATHS[packet.chain] ?? [packet.chain]
+ return path.flatMap(chain => effectiveRules.filter(r => r.chain === chain))
+ }, [effectiveRules, packet.chain])
+
+ // ── Handlers ────────────────────────────────────────────────────────────────
+ function resetSim() {
+ runEpoch.current += 1
+ cancelRef.current = true
+ setSimState("idle")
+ setSteps([])
+ setTracePage(0)
+ setVerdict(null)
+ }
+
+ function applyPreset(p: typeof SIM_PRESETS[number]) {
+ setActiveScenarioId(null)
+ setPreset(p.id)
+ setPacket({ ...DEFAULT_PKT, ...p.pkt })
+ runEpoch.current += 1
+ cancelRef.current = true
+ setSimState("idle")
+ setSteps([])
+ setTracePage(0)
+ setVerdict(null)
+ }
+
+ function activateScenario(id: string | null) {
+ setActiveScenarioId(id)
+ setPreset(null)
+ runEpoch.current += 1
+ cancelRef.current = true
+ setSimState("idle")
+ setSteps([])
+ setTracePage(0)
+ setVerdict(null)
+ if (id) {
+ const sc = scenarios.find(s => s.id === id)
+ if (sc) setPacket(sc.packet)
+ }
+ }
+
+ function saveScenario(sc: SimScenario) {
+ setScenarios(prev => {
+ const idx = prev.findIndex(x => x.id === sc.id)
+ if (idx >= 0) { const next = [...prev]; next[idx] = sc; return next }
+ return [...prev, sc]
+ })
+ activateScenario(sc.id)
+ }
+
+ function deleteScenario(id: string) {
+ setScenarios(prev => prev.filter(s => s.id !== id))
+ if (activeScenarioId === id) { setActiveScenarioId(null); resetSim() }
+ }
+
+ async function runSimulation() {
+ const path = DIR_PATHS[packet.chain] ?? [packet.chain]
+ const rules = path.flatMap(chain => effectiveRules.filter(r => r.chain === chain))
+ if (rules.length === 0) {
+ setVerdict({ action: "accept (default)", ruleIndex: null })
+ setSimState("done")
+ return
+ }
+
+ const epoch = ++runEpoch.current
+ cancelRef.current = false
+ setTracePage(0)
+ setSimState("running")
+ setVerdict(null)
+ setSteps(rules.map((r, i) => ({ rule: r, index: i, status: "pending", checks: [] })))
+
+ const delay = SIM_SPEED_MS[speed]
+ let finalVerdict: { action: string; ruleIndex: number | null } | null = null
+
+ for (let i = 0; i < rules.length; i++) {
+ if (cancelRef.current || epoch !== runEpoch.current) return
+ setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: "evaluating" } : s))
+ if (delay > 0) await new Promise(res => setTimeout(res, delay))
+ if (cancelRef.current || epoch !== runEpoch.current) return
+
+ const result = evalRule(rules[i], packet)
+ setSteps(prev => prev.map((s, idx) =>
+ idx === i ? { ...s, status: result.verdict, checks: result.checks, failedAt: result.failedAt } : s,
+ ))
+
+ if (result.verdict === "match") {
+ finalVerdict = { action: rules[i].action, ruleIndex: i }
+ if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.5, 200)))
+ break
+ }
+ if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.25, 40)))
+ }
+
+ if (!cancelRef.current && epoch === runEpoch.current) {
+ setVerdict(finalVerdict ?? { action: "accept (default)", ruleIndex: null })
+ setSimState("done")
+ }
+ }
+
+ const tracePageCount = Math.max(1, Math.ceil(steps.length / 50))
+ const currentTracePage = Math.min(tracePage, tracePageCount - 1)
+ const traceOffset = currentTracePage * 50
+ const traceChainCounts = useMemo(() => {
+ const counts = new Map()
+ for (const step of steps) counts.set(step.rule.chain, (counts.get(step.rule.chain) ?? 0) + 1)
+ return counts
+ }, [steps])
+
+ const verdictColors = verdict ? getVerdictColors(verdict.action) : null
+
+ return (
+
+
+ {/* ── Presets + Scenarios card ─────────────────────────────────────────── */}
+
+
+ {SIM_PRESETS.map(p => (
+ applyPreset(p)}
+ className={cn(
+ "flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-colors",
+ activePreset === p.id && !activeScenarioId
+ ? "bg-primary/90 text-primary-foreground border-primary"
+ : "border-border bg-background hover:bg-muted/60 text-foreground",
+ )}>
+ {p.emoji}
+ {p.label}
+ {p.desc}
+
+ ))}
+
+
+
+
+ {/* Saved scenarios */}
+
+
+
+ Мои сценарии
+
+
{ setEditingScenario(null); setScenarioSheetOpen(true) }}>
+ Новый сценарий
+
+
+
+ {scenarios.length === 0 ? (
+
+ Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования
+
+ ) : (
+
+ {scenarios.map(sc => {
+ const isActive = activeScenarioId === sc.id
+ return (
+
+
+ {sc.name}
+
+ {sc.rules.length} правил · {sc.packet.chain}
+ {sc.description ? ` · ${sc.description}` : ""}
+
+
+
+
activateScenario(isActive ? null : sc.id)}
+ className={cn(
+ "text-[11px] font-mono px-2 py-0.5 rounded border transition-colors",
+ isActive
+ ? "bg-violet-500 text-white border-violet-500"
+ : "border-border hover:bg-muted",
+ )}>
+ {isActive ? "Активен" : "Выбрать"}
+
+
{ setEditingScenario(sc); setScenarioSheetOpen(true) }}
+ aria-label={`Редактировать сценарий ${sc.name}`}
+ className="size-11 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors">
+
+
+
deleteScenario(sc.id)}
+ aria-label={`Удалить сценарий ${sc.name}`}
+ className="size-11 inline-flex items-center justify-center text-muted-foreground/50 hover:text-red-500 transition-colors">
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {/* ── Packet editor ───────────────────────────────────────────────────── */}
+
+
+
+
+ {activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"}
+
+
+
+ {simRules.length} правил ·{" "}
+ {packet.chain}
+
+ {DIR_PATHS[packet.chain] && (
+
+ {DIR_PATHS[packet.chain].join(" → ")}
+
+ )}
+
+
+
+
+ {/* Row 1 */}
+
+
+ Направление / цепочка
+ { setPkt("chain", v); resetSim() }}>
+
+ forward — транзит LAN→WAN
+ input — на роутер
+ output — от роутера
+ dstnat — port-forward
+
+
+ prerouting
+ postrouting
+ srcnat
+ ip6-forward
+ ip6-input
+ ip6-output
+
+
+
+
+ Протокол
+ setPkt("proto", v)}>
+ {["tcp","udp","icmp","gre","esp","ah"].map(p => {p} )}
+
+
+
+ Src IP
+ setPkt("srcAddr", e.target.value)} />
+
+
+ Dst IP
+ setPkt("dstAddr", e.target.value)} />
+
+
+ Src Port
+ setPkt("srcPort", e.target.value)} />
+
+
+ Dst Port
+ setPkt("dstPort", e.target.value)} />
+
+
+
+ {/* Row 2 */}
+
+
+ {/* Speed + Run controls */}
+
+
+
+ {(["slow","normal","fast","instant"] as SimSpeed[]).map(s => (
+ setSpeed(s)} disabled={simState === "running"}
+ className={cn(
+ "px-3 py-1 text-xs rounded transition-colors disabled:opacity-50",
+ speed === s ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
+ )}>
+ {SIM_SPEED_LABELS[s]}
+
+ ))}
+
+
+
+ Сбросить
+
+ {simState === "running" ? (
+
{ runEpoch.current += 1; cancelRef.current = true; setSimState("done") }}>
+ Стоп
+
+ ) : (
+
+
+ {simState === "done" ? "Снова" : "Запустить"}
+
+ )}
+
+
+
+
+ {/* ── Verdict banner ──────────────────────────────────────────────────── */}
+ {verdict && simState === "done" && verdictColors && (
+
+
{getVerdictIcon(verdict.action)}
+
+
+ {verdict.action === "drop" || verdict.action === "reject"
+ ? "Пакет заблокирован"
+ : verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"}
+ {verdict.ruleIndex !== null && (
+ — правило #{verdict.ruleIndex + 1}
+ )}
+
+
+ Действие: {verdict.action}
+ {verdict.ruleIndex === null && · ни одно правило не совпало }
+
+
+
+ )}
+
+ {/* ── Trace list ──────────────────────────────────────────────────────── */}
+ {steps.length > 0 && (
+
+
+
+
+ Трассировка · цепочка: {packet.chain}
+ {activeScenario && (
+ · {activeScenario.name}
+ )}
+
+
+ {steps.filter(s => s.status === "match").length} совпало
+ {" · "}{steps.filter(s => s.status === "skip").length} пропущено
+ {" · "}{steps.filter(s => s.status === "disabled").length} выкл
+
+
+
+ {steps.slice(traceOffset, traceOffset + 50).map((step, pageIndex) => {
+ const i = traceOffset + pageIndex
+ const prevChain = i > 0 ? steps[i - 1].rule.chain : null
+ const showHeader = pageIndex === 0 || step.rule.chain !== prevChain
+ return (
+
+ {showHeader && (
+
+
+ {step.rule.chain}
+
+ {CHAIN_TABLE_HINT[step.rule.chain] && (
+
+ · {CHAIN_TABLE_HINT[step.rule.chain]}
+
+ )}
+
+ {traceChainCounts.get(step.rule.chain)} правил
+
+
+ )}
+
+
+ )
+ })}
+
+
+
Шаги {traceOffset + 1}–{Math.min(traceOffset + 50, steps.length)} из {steps.length}
+
+ setTracePage(currentTracePage - 1)}>Назад
+ = tracePageCount - 1} onClick={() => setTracePage(currentTracePage + 1)}>Далее
+ {verdict?.ruleIndex != null && setTracePage(Math.floor(verdict.ruleIndex! / 50))}>К правилу результата }
+
+
+
+
+ )}
+
+ {/* ── Empty state ─────────────────────────────────────────────────────── */}
+ {simState === "idle" && simRules.length === 0 && (
+
+
+
Нет правил в цепочке «{packet.chain}»
+
+ {activeScenario
+ ? "Добавьте правила в сценарий через кнопку «Редактировать»"
+ : "Выберите другой пресет или измените цепочку"}
+
+
+ )}
+
+ {/* ── Scenario Sheet ───────────────────────────────────────────────────── */}
+
setScenarioSheetOpen(false)}
+ initial={editingScenario}
+ onSave={saveScenario}
+ />
+
+
+ )
+}
+
+// ─── Live API ─────────────────────────────────────────────────────────────────
+
+function makeApiFetch(backendUrl: string) {
+ return async function apiFetch(path: string, init?: RequestInit): Promise {
+ return requestJson(backendUrl, path, init)
+ }
+}
+
+function mapBackendToServer(s: FirewallBackendServer): Server {
+ return {
+ id: String(s.id),
+ name: s.name || s.host,
+ host: s.host,
+ model: "—",
+ os: "—",
+ site: s.site,
+ country: s.country || "UN",
+ asn: s.asn,
+ type: s.type,
+ enabled: s.enabled,
+ status: (s.status ?? "offline") as Server["status"],
+ latency: s.latency != null ? Math.round(s.latency) : null,
+ sessions: 0,
+ }
+}
+
+function ruleFamily(r: Pick): IpFamily {
+ return r.family === "ip6" ? "ip6" : "ip"
+}
+
+function addressFamily(e: Pick): IpFamily {
+ return e.family === "ip6" ? "ip6" : "ip"
+}
+
+function ruleKey(r: FirewallRule) {
+ if (!r.serverId || !r.rosId || !r.table) return null
+ return { serverId: r.serverId, family: ruleFamily(r), table: r.table, rosId: r.rosId }
+}
+
+function sameRuleGroup(a: FirewallRule, b: FirewallRule): boolean {
+ return (
+ a.serverId === b.serverId
+ && ruleFamily(a) === ruleFamily(b)
+ && (a.table ?? "filter") === (b.table ?? "filter")
+ )
+}
+
+function moveInArray(arr: T[], from: number, to: number): T[] {
+ if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr
+ const copy = [...arr]
+ const [moved] = copy.splice(from, 1)
+ if (moved === undefined) return arr
+ copy.splice(to, 0, moved)
+ return copy
+}
+
+function reorderWithinGroup(list: FirewallRule[], activeId: string, overId: string): FirewallRule[] | null {
+ const active = list.find((r) => r.id === activeId)
+ const over = list.find((r) => r.id === overId)
+ if (!active || !over || !sameRuleGroup(active, over)) return null
+ const same = (r: FirewallRule) => sameRuleGroup(r, active)
+ const group = list.filter(same)
+ const from = group.findIndex((r) => r.id === activeId)
+ const to = group.findIndex((r) => r.id === overId)
+ if (from < 0 || to < 0 || from === to) return null
+ const nextGroup = moveInArray(group, from, to)
+ let i = 0
+ return list.map((r) => (same(r) ? nextGroup[i++]! : r))
+}
+
+/** RouterOS move: insert-before destination. Omit dest = конец таблицы. */
+function rosMoveDestination(list: FirewallRule[], activeId: string, overId: string): { dest?: string } | null {
+ const active = list.find((r) => r.id === activeId)
+ const over = list.find((r) => r.id === overId)
+ if (!active || !over || !sameRuleGroup(active, over)) return null
+ const group = list.filter((r) => sameRuleGroup(r, active))
+ const from = group.findIndex((r) => r.id === activeId)
+ const to = group.findIndex((r) => r.id === overId)
+ if (from < 0 || to < 0 || from === to) return null
+ if (from < to) {
+ const dest = group[to + 1]?.rosId
+ return dest ? { dest } : {}
+ }
+ const dest = group[to]?.rosId
+ return dest ? { dest } : {}
+}
+
+function addressKey(e: AddressListEntry) {
+ if (!e.serverId || !e.rosId) return null
+ return { serverId: e.serverId, family: addressFamily(e), rosId: e.rosId }
+}
+
+function payloadToMockRule(
+ payload: RuleWritePayload,
+ opts: { id: string; table: FirewallTable; family: IpFamily; serverId: string; serverName: string; rosId: string },
+): FirewallRule {
+ return {
+ id: opts.id,
+ table: opts.table,
+ family: opts.family,
+ serverId: opts.serverId,
+ serverName: opts.serverName,
+ rosId: opts.rosId,
+ chain: payload.chain,
+ action: payload.action,
+ proto: payload.protocol ?? "all",
+ src: payload.srcAddress ?? payload.srcAddressList ?? "—",
+ dst: payload.dstAddress ?? payload.dstAddressList ?? "—",
+ port: payload.dstPort ?? payload.srcPort ?? "—",
+ iface: payload.inInterface ?? payload.outInterface ?? "—",
+ comment: payload.comment ?? "",
+ enabled: !payload.disabled,
+ hits: 0,
+ log: payload.log,
+ logPrefix: payload.logPrefix,
+ }
+}
+
+// ════════════════════════════════════════════════════════════════════════════
+function FirewallPageInner() {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+ const mounted = useRef(true)
+ useEffect(() => {
+ mounted.current = true
+ return () => { mounted.current = false }
+ }, [])
+ const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
+ const searchParams = useSearchParams()
+ const serverFromQuery = searchParams.get("server")
+
+ const [liveServers, setLiveServers] = useState([])
+ const [liveRules, setLiveRules] = useState([])
+ const [liveAddrLists, setLiveAddrLists] = useState([])
+ const [mockRules, setMockRules] = useState(firewallRules)
+ const [mockAddrLists, setMockAddrLists] = useState(INIT_ADDRESS_LISTS)
+ const [dataLoading, setDataLoading] = useState(false)
+ const [dataError, setDataError] = useState(null)
+ const [hasLoaded, setHasLoaded] = useState(false)
+ const [readTrusted, setReadTrusted] = useState(false)
+ const [sliceTimes, setSliceTimes] = useState([])
+ const [failures, setFailures] = useState([])
+ const loadEpoch = useRef(0)
+ useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
+ const [busy, setBusy] = useState(false)
+ const [formError, setFormError] = useState(null)
+ const mutationLock = useRef(false)
+ const [togglePending, setTogglePending] = useState(false)
+ const [movePending, setMovePending] = useState(false)
+ const [moveStatus, setMoveStatus] = useState(null)
+ const [moveError, setMoveError] = useState(null)
+ const [toggleStatus, setToggleStatus] = useState(null)
+ const [toggleError, setToggleError] = useState(null)
+ const [pendingDelete, setPendingDelete] = useState<{ kind: "rule"; item: FirewallRule } | { kind: "address"; item: AddressListEntry } | null>(null)
+ const [deletePending, setDeletePending] = useState(false)
+ const [deleteError, setDeleteError] = useState(null)
+
+ const [selectedServerId, setSelectedServerId] = useState(serverFromQuery ?? ALL_SERVERS_ID)
+ const [queryApplied, setQueryApplied] = useState(false)
+
+ const [chainGroup, setChainGroup] = useState("filter")
+ const [chainFilter, setChainFilter] = useState("all")
+ const [ipFamily, setIpFamily] = useState("ip")
+ const [search, setSearch] = useState("")
+
+ const [editingRule, setEditingRule] = useState | null>(null)
+ const [ruleSheetOpen, setRuleSheetOpen] = useState(false)
+ const [exportOpen, setExportOpen] = useState(false)
+ const [editingAddr, setEditingAddr] = useState | null>(null)
+ const [addrSheetOpen, setAddrSheetOpen] = useState(false)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [revisions, setRevisions] = useState([])
+ const [historyLoading, setHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+ const historyEpoch = useRef(0)
+ const [historyRestoring, setHistoryRestoring] = useState(false)
+
+ const loadLive = useCallback(async () => {
+ if (!isLive || !mounted.current) return
+ const epoch = ++loadEpoch.current
+ setDataLoading(true)
+ setDataError(null)
+ try {
+ const [backendServers, fw] = await Promise.all([
+ apiFetch("/api/servers"),
+ apiFetch("/api/firewall/all"),
+ ])
+ if (epoch !== loadEpoch.current) return
+ const servers = firewallServersSchema.safeParse(backendServers)
+ const firewall = firewallResponseSchema.safeParse(fw)
+ if (!servers.success || !firewall.success) throw new Error("Сервер вернул некорректные данные Firewall")
+ setLiveServers(servers.data.map(mapBackendToServer))
+ const enabledIds = new Set(servers.data.filter(server => server.enabled).map(server => String(server.id)))
+ setLiveRules(previous => mergeFirewallRows(previous, firewall.data.rules, firewall.data.failures, enabledIds))
+ setLiveAddrLists(previous => mergeFirewallRows(previous, firewall.data.addressLists, firewall.data.failures, enabledIds, true))
+ setFailures(firewall.data.failures)
+ const receivedAt = new Date().toISOString()
+ setSliceTimes(previous => updateFirewallSliceTimes(previous, [...enabledIds], firewall.data.failures, receivedAt))
+ setHasLoaded(true)
+ setReadTrusted(true)
+ } catch (e) {
+ if (epoch !== loadEpoch.current) return
+ setReadTrusted(false)
+ setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
+ } finally {
+ if (epoch === loadEpoch.current) setDataLoading(false)
+ }
+ }, [isLive, apiFetch])
+
+ const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
+
+ useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
+
+ const loadRevisions = useCallback(async () => {
+ if (!isLive || !historyServerId || !mounted.current) return
+ const epoch = ++historyEpoch.current
+ setHistoryLoading(true)
+ setHistoryError(null)
+ try {
+ const res = await apiFetch(
+ `/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
+ )
+ if (epoch !== historyEpoch.current) return
+ setRevisions(parseConfigRevisions(res, historyServerId, "firewall"))
+ } catch (error) {
+ if (epoch !== historyEpoch.current) return
+ setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
+ } finally {
+ if (epoch === historyEpoch.current) setHistoryLoading(false)
+ }
+ }, [isLive, historyServerId, apiFetch])
+
+ const restoreRevision = useCallback(async (id: string) => {
+ if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
+ if (!isLive || !historyServerId) throw new Error("Выберите устройство в режиме Live")
+ if (mutationLock.current) throw new Error("Дождитесь завершения текущей операции Firewall")
+ if (!readTrusted) throw new Error("Обновите актуальные данные Firewall перед восстановлением")
+ if (failures.some(f => f.serverId === historyServerId)) throw new Error("Обновите полный набор данных устройства перед восстановлением")
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setHistoryRestoring(true)
+ try {
+ const res = await apiFetch<{ ok: boolean }>(
+ `/api/firewall/revisions/${encodeURIComponent(id)}/restore`,
+ { method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
+ )
+ if (!mounted.current) return
+ if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
+ toast.success("Версия применена на роутер")
+ await loadLive()
+ await loadRevisions()
+ } catch (err) {
+ throw err
+ } finally {
+ mutationLock.current = false
+ setHistoryRestoring(false)
+ }
+ }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions, failures, readTrusted])
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setLiveServers([])
+ setLiveRules([])
+ setLiveAddrLists([])
+ setHasLoaded(false)
+ setSliceTimes([])
+ setFailures([])
+ setDataError(null)
+ setDataLoading(false)
+ if (isLive) void loadLive()
+ })
+ }, [isLive, loadLive])
+
+ useEffect(() => {
+ if (dataError) toast.error(dataError)
+ }, [dataError])
+
+ const displayRules = isLive ? liveRules : mockRules
+ const displayAddrLists = isLive ? liveAddrLists : mockAddrLists
+ const displayServers = isLive ? liveServers : mockServers
+
+ if (!queryApplied && serverFromQuery && displayServers.length > 0) {
+ if (displayServers.some(server => server.id === serverFromQuery)) setSelectedServerId(serverFromQuery)
+ setQueryApplied(true)
+ }
+
+ const scopedRules = useMemo(() => {
+ if (selectedServerId === ALL_SERVERS_ID) return displayRules
+ return displayRules.filter((r) => r.serverId === selectedServerId)
+ }, [displayRules, selectedServerId])
+
+ const scopedAddrLists = useMemo(() => {
+ if (selectedServerId === ALL_SERVERS_ID) return displayAddrLists
+ return displayAddrLists.filter((e) => e.serverId === selectedServerId)
+ }, [displayAddrLists, selectedServerId])
+
+ const familyRules = useMemo(
+ () => scopedRules.filter((r) => ruleFamily(r) === ipFamily),
+ [scopedRules, ipFamily],
+ )
+ const familyAddrLists = useMemo(
+ () => scopedAddrLists.filter((e) => addressFamily(e) === ipFamily),
+ [scopedAddrLists, ipFamily],
+ )
+
+ const firewallRailItems = useMemo(() => (
+ displayServers.map((s) => ({
+ id: s.id,
+ name: s.name,
+ host: s.host,
+ site: s.site,
+ country: s.country,
+ status: s.status,
+ type: s.type,
+ enabled: s.enabled,
+ meta: String(displayRules.filter((r) => r.serverId === s.id).length),
+ }))
+ ), [displayServers, displayRules])
+
+ const writeServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
+ const writeServer = displayServers.find((s) => s.id === writeServerId)
+
+ const chainsInGroup = useMemo(() => {
+ if (chainGroup === "address-lists" || chainGroup === "simulator") return []
+ return CHAIN_GROUPS.find((g) => g.id === chainGroup)?.chains ?? []
+ }, [chainGroup])
+
+ const groupRules = useMemo(() => {
+ const table = tableOfGroup(chainGroup)
+ if (!table) return []
+ return familyRules.filter((r) => r.table === table)
+ }, [familyRules, chainGroup])
+
+ const filteredRules = useMemo(() => {
+ return groupRules.filter((r) => {
+ if (chainFilter !== "all" && r.chain !== chainFilter) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return (
+ r.chain.toLowerCase().includes(q) ||
+ r.action.toLowerCase().includes(q) ||
+ r.src.toLowerCase().includes(q) ||
+ r.dst.toLowerCase().includes(q) ||
+ r.comment.toLowerCase().includes(q) ||
+ (r.serverName ?? "").toLowerCase().includes(q)
+ )
+ })
+ }, [groupRules, chainFilter, search])
+
+ const chainCounts = useMemo(() => {
+ const counts: Record = { all: groupRules.length }
+ for (const r of groupRules) {
+ counts[r.chain] = (counts[r.chain] ?? 0) + 1
+ }
+ return counts
+ }, [groupRules])
+
+ const scopedFailures = isLive ? failures.filter(f => (selectedServerId === ALL_SERVERS_ID || f.serverId === selectedServerId) && (!f.family || f.family === ipFamily)) : []
+ const firewallIncomplete = scopedFailures.length > 0
+ const firewallWriteBlocked = (isLive && !can("mm:network:write")) || firewallIncomplete || (isLive && !readTrusted)
+ const scopedSliceTimes = sliceTimes.filter(slice => (selectedServerId === ALL_SERVERS_ID || slice.serverId === selectedServerId) && slice.family === ipFamily)
+ const freshness = firewallTimeRange(scopedSliceTimes)
+ const totalEnabled = familyRules.filter((r) => r.enabled).length
+ const totalHits = familyRules.reduce((s, r) => s + r.hits, 0)
+ const dropRules = familyRules.filter((r) => r.action === "drop" || r.action === "reject").length
+ const showServerCol = selectedServerId === ALL_SERVERS_ID
+
+ function requireWriteServer(): string | null {
+ if (writeServerId) return writeServerId
+ toast.info("Выберите сервер в панели слева")
+ return null
+ }
+
+ async function toggleRule(id: string) {
+ const r = displayRules.find((x) => x.id === id)
+ if (!r || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ if (isLive) {
+ const key = ruleKey(r)
+ if (!key) return
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setTogglePending(true)
+ setToggleError(null)
+ setToggleStatus(`Ожидание изменения правила ${r.comment || r.rosId || r.id}…`)
+ try {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
+ method: "PATCH",
+ body: JSON.stringify({ ...key, disabled: r.enabled }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение правила")
+ await loadLive()
+ } catch (e) {
+ setToggleError(`${r.comment || r.rosId || r.id}: ${e instanceof Error ? e.message : "Не удалось переключить правило"}`)
+ } finally {
+ mutationLock.current = false
+ setTogglePending(false)
+ setToggleStatus(null)
+ }
+ return
+ }
+ setMockRules((rs) => rs.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))
+ }
+
+ function requestDelete(target: NonNullable) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ setDeleteError(null)
+ setPendingDelete(target)
+ }
+
+ async function confirmDelete() {
+ if (!pendingDelete || mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ const target = pendingDelete
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setDeletePending(true)
+ setDeleteError(null)
+ try {
+ if (isLive) {
+ const key = target.kind === "rule" ? ruleKey(target.item) : addressKey(target.item)
+ if (!key) throw new Error("Не удалось определить устройство и ID объекта")
+ const result = await apiFetch<{ ok: boolean }>(target.kind === "rule" ? "/api/firewall/rules" : "/api/firewall/address-lists", {
+ method: "DELETE", body: JSON.stringify(key),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
+ await loadLive()
+ } else if (target.kind === "rule") {
+ setMockRules(rows => rows.filter(x => x.id !== target.item.id))
+ } else {
+ setMockAddrLists(rows => rows.filter(x => x.id !== target.item.id))
+ }
+ if (!mounted.current) return
+ setPendingDelete(null)
+ toast.success(target.kind === "rule" ? "Правило удалено" : "Запись удалена")
+ } catch (e) {
+ setDeleteError(e instanceof Error ? e.message : "Не удалось удалить объект")
+ } finally {
+ mutationLock.current = false
+ setDeletePending(false)
+ }
+ }
+
+ const canReorder = useMemo(() => {
+ if (filteredRules.length < 2) return false
+ const servers = new Set(filteredRules.map((r) => r.serverId).filter(Boolean))
+ return servers.size === 1
+ }, [filteredRules])
+
+ async function reorderRules(activeId: string, overId: string) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ const source = isLive ? liveRules : mockRules
+ const next = reorderWithinGroup(source, activeId, overId)
+ if (!next) {
+ toast.info("Правила можно переставлять только в пределах одного сервера и таблицы")
+ return
+ }
+ if (!isLive) {
+ setMockRules(next)
+ return
+ }
+ const active = source.find((r) => r.id === activeId)
+ const key = active ? ruleKey(active) : null
+ if (!key) {
+ toast.error("У правила нет идентификатора RouterOS")
+ return
+ }
+ const dest = rosMoveDestination(source, activeId, overId)
+ if (!dest) return
+ const prev = source
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setMovePending(true)
+ setMoveError(null)
+ setMoveStatus("Ожидание перемещения правила " + (active?.comment || active?.rosId || activeId) + "…")
+ setLiveRules(next)
+ try {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules/move", {
+ method: "POST",
+ body: JSON.stringify({
+ ...key,
+ ...(dest.dest ? { destinationRosId: dest.dest } : {}),
+ }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил перемещение правила")
+ await loadLive()
+ } catch (e) {
+ setLiveRules(prev)
+ setMoveError((active?.comment || active?.rosId || activeId) + ": " + (e instanceof Error ? e.message : "Не удалось переместить правило"))
+ } finally {
+ mutationLock.current = false
+ setMovePending(false)
+ setMoveStatus(null)
+ }
+ }
+
+ function openAdd() {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ setFormError(null)
+ if (!requireWriteServer()) return
+ const table = tableOfGroup(chainGroup)
+ if (!table) {
+ setChainGroup("filter")
+ }
+ setEditingRule(null)
+ setRuleSheetOpen(true)
+ }
+
+ function openEdit(r: FirewallRule) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ setFormError(null)
+ setEditingRule(r)
+ setRuleSheetOpen(true)
+ }
+
+ async function saveRule(payload: RuleWritePayload) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ const table = (editingRule?.table ?? tableOfGroup(chainGroup) ?? "filter") as FirewallTable
+ const family = editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily
+ const serverId = editingRule?.serverId ?? requireWriteServer()
+ if (!serverId) return
+ const serverName = editingRule?.serverName
+ ?? displayServers.find((s) => s.id === serverId)?.name
+ ?? writeServer?.name
+ ?? serverId
+
+ if (isLive) {
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setBusy(true)
+ setFormError(null)
+ try {
+ if (editingRule?.rosId) {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
+ method: "PUT",
+ body: JSON.stringify({ serverId, family, table, rosId: editingRule.rosId, ...payload }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
+ toast.success("Правило сохранено")
+ } else {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/rules", {
+ method: "POST",
+ body: JSON.stringify({ serverId, family, table, ...payload }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
+ toast.success("Правило создано")
+ }
+ setRuleSheetOpen(false)
+ await loadLive()
+ } catch (e) {
+ setFormError(e instanceof Error ? e.message : "Не удалось сохранить правило")
+ } finally {
+ mutationLock.current = false
+ setBusy(false)
+ }
+ return
+ }
+
+ if (editingRule?.id) {
+ setMockRules((rs) => rs.map((x) => x.id === editingRule.id
+ ? { ...x, ...payloadToMockRule(payload, {
+ id: x.id,
+ table,
+ family,
+ serverId,
+ serverName,
+ rosId: x.rosId ?? "*0",
+ }) }
+ : x))
+ } else {
+ const id = `fw-${Date.now()}`
+ setMockRules((rs) => [...rs, payloadToMockRule(payload, {
+ id,
+ table,
+ family,
+ serverId,
+ serverName,
+ rosId: `*${Date.now()}`,
+ })])
+ }
+ setRuleSheetOpen(false)
+ }
+
+ function openAddAddr(list?: string) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ setFormError(null)
+ if (!requireWriteServer()) return
+ setEditingAddr(list ? { list } : null)
+ setAddrSheetOpen(true)
+ }
+
+ function openEditAddr(e: AddressListEntry) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ setFormError(null)
+ setEditingAddr(e)
+ setAddrSheetOpen(true)
+ }
+
+ async function toggleAddr(e: AddressListEntry) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ if (isLive) {
+ const key = addressKey(e)
+ if (!key) return
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setTogglePending(true)
+ setToggleError(null)
+ setToggleStatus(`Ожидание изменения адреса ${e.address} (${e.list})…`)
+ try {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
+ method: "PATCH",
+ body: JSON.stringify({ ...key, disabled: !e.disabled }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение адреса")
+ await loadLive()
+ } catch (err) {
+ setToggleError(`${e.address} (${e.list}): ${err instanceof Error ? err.message : "Не удалось переключить запись"}`)
+ } finally {
+ mutationLock.current = false
+ setTogglePending(false)
+ setToggleStatus(null)
+ }
+ return
+ }
+ setMockAddrLists((rows) => rows.map((x) => x.id === e.id ? { ...x, disabled: !x.disabled } : x))
+ }
+
+ async function saveAddr(payload: {
+ list: string
+ address: string
+ comment?: string
+ timeout?: string
+ disabled?: boolean
+ }) {
+ if (mutationLock.current || busy || historyRestoring || firewallWriteBlocked) return
+ const family = editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily
+ const serverId = editingAddr?.serverId ?? requireWriteServer()
+ if (!serverId) return
+ const serverName = editingAddr?.serverName
+ ?? displayServers.find((s) => s.id === serverId)?.name
+ ?? writeServer?.name
+ ?? serverId
+
+ if (isLive) {
+ mutationLock.current = true
+ loadEpoch.current += 1
+ setDataLoading(false)
+ setBusy(true)
+ setFormError(null)
+ try {
+ if (editingAddr?.rosId) {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
+ method: "PUT",
+ body: JSON.stringify({ serverId, family, rosId: editingAddr.rosId, ...payload }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
+ toast.success("Запись сохранена")
+ } else {
+ const result = await apiFetch<{ ok: boolean }>("/api/firewall/address-lists", {
+ method: "POST",
+ body: JSON.stringify({ serverId, family, ...payload }),
+ })
+ if (!mounted.current) return
+ if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение")
+ toast.success("Запись создана")
+ }
+ setAddrSheetOpen(false)
+ await loadLive()
+ } catch (err) {
+ setFormError(err instanceof Error ? err.message : "Не удалось сохранить запись")
+ } finally {
+ mutationLock.current = false
+ setBusy(false)
+ }
+ return
+ }
+
+ if (editingAddr?.id) {
+ setMockAddrLists((rows) => rows.map((x) => x.id === editingAddr.id
+ ? { ...x, ...payload, family, serverId, serverName }
+ : x))
+ } else {
+ setMockAddrLists((rows) => [...rows, {
+ id: `al-${Date.now()}`,
+ list: payload.list,
+ address: payload.address,
+ comment: payload.comment ?? "",
+ timeout: payload.timeout,
+ disabled: payload.disabled ?? false,
+ family,
+ serverId,
+ serverName,
+ rosId: `*${Date.now()}`,
+ }])
+ }
+ setAddrSheetOpen(false)
+ }
+
+ const sheetChainGroup: ChainGroup =
+ chainGroup === "address-lists" || chainGroup === "simulator" ? "filter" : chainGroup
+
+ return (
+ <>
+ Режим просмотра. Изменение конфигурации недоступно. : undefined}
+ header={
+
+
+ { if (!mutationLock.current) void loadLive() }}
+ disabled={!isLive || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring}
+ title={!isLive ? "Включите Live в источнике данных" : "Обновить правила с устройств"}
+ >
+
+ Обновить
+
+ {
+ if (mutationLock.current) return
+ setHistoryOpen(true)
+ void loadRevisions()
+ }}
+ disabled={!isLive || !historyServerId || dataLoading || busy || togglePending || deletePending || movePending || historyRestoring || !readTrusted || failures.some(f => f.serverId === historyServerId)}
+ title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
+ >
+
+ История
+
+ setExportOpen(true)}>
+ Экспорт .rsc
+
+
+ Новое правило
+
+ >
+ }
+ />
+ }
+ >
+
+ {dataError && isLive && (
+
+ {dataError}
+ {hasLoaded && Показаны последние успешно загруженные данные. }
+
+ )}
+
+ {isLive && hasLoaded && !readTrusted &&
Изменения Firewall заблокированы до успешного чтения актуальных данных. Последний список сохранён.
}
+
+
+ { setIpFamily(v); setChainFilter("all") }}
+ options={(["ip", "ip6"] as IpFamily[]).map((f) => ({
+ value: f,
+ label: IP_FAMILY_LABELS[f],
+ }))}
+ />
+
+ {ipFamily === "ip6" ? "/ipv6 firewall" : "/ip firewall"}
+
+
+
+ {isLive &&
+ {dataLoading &&
{hasLoaded ? "Обновление данных Firewall…" : "Загрузка данных Firewall…"}
}
+
+ {freshness.oldest ? <>Получено в приложении: {new Date(freshness.oldest).toLocaleString("ru-RU")}
+ {freshness.newest !== freshness.oldest && <> — {new Date(freshness.newest!).toLocaleString("ru-RU")} >}
+ > : hasLoaded && liveServers.every(server => !server.enabled) ? "Нет включённых устройств для опроса" : "Время получения срезов неизвестно"}
+ {freshness.unknown > 0 && <> · Не получено срезов: {freshness.unknown}>}
+
+
}
+ {firewallIncomplete &&
+
Не все срезы Firewall получены
+
Сохранены последние данные недоступных срезов; их состояние неизвестно. Обновите данные перед изменениями.
+ {scopedFailures.map((failure, index) => {
+ const range = firewallTimeRange(scopedSliceTimes.filter(slice => slice.serverId === failure.serverId && (!failure.family || slice.family === failure.family) && (!failure.table || slice.table === failure.table)))
+ return
{[failure.serverName || failure.serverId, failure.family, failure.table, failure.error].filter(Boolean).join(" · ")} · Последние данные: {range.oldest
+ ? <>{new Date(range.oldest).toLocaleString("ru-RU")}
+ {range.newest !== range.oldest && <> — {new Date(range.newest!).toLocaleString("ru-RU")} >}
+ > : "ещё не получены"}{range.unknown > 0 && <> · Не получено срезов: {range.unknown}>}
+ })}
+
}
+ {moveStatus &&
{moveStatus}
}
+ {moveError &&
{moveError}
}
+ {toggleStatus &&
{toggleStatus}
}
+ {toggleError &&
{toggleError}
}
+
,
+ iconClassName: "text-muted-foreground",
+ },
+ {
+ id: "enabled",
+ label: "Активных",
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : totalEnabled,
+ icon:
,
+ iconClassName: "text-success",
+ },
+ {
+ id: "drop",
+ label: "Блокирующих",
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : dropRules,
+ icon:
,
+ iconClassName: "text-destructive",
+ },
+ {
+ id: "hits",
+ label: "Срабатываний",
+ value: isLive && (!hasLoaded || firewallIncomplete) ? "—" : fmtHits(totalHits),
+ icon:
,
+ iconClassName: "text-info",
+ },
+ ]}
+ />
+
+
+ {CHAIN_GROUPS.map((g) => (
+ { setChainGroup(g.id); setChainFilter("all"); setSearch("") }}
+ className={cn(
+ "flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px",
+ chainGroup === g.id
+ ? "border-foreground text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}>
+ {g.icon}
+ {g.label}
+ {g.id !== "address-lists" && g.id !== "simulator" && (
+
+ {familyRules.filter((r) => r.table === g.id).length}
+
+ )}
+ {g.id === "address-lists" && (
+ {familyAddrLists.length}
+ )}
+
+ ))}
+
+
+ {isLive && (!hasLoaded || (firewallIncomplete && (chainGroup === "address-lists" ? familyAddrLists.length === 0 : filteredRules.length === 0))) ? (
+
{dataLoading ? "Загрузка данных Firewall…" : "Данные Firewall пока недоступны. Повторите загрузку."}
+ ) : chainGroup === "address-lists" ? (
+
openAddAddr()}
+ onAddToList={(list) => openAddAddr(list)}
+ onToggle={(e) => { void toggleAddr(e) }}
+ onEdit={openEditAddr}
+ onDelete={(item) => requestDelete({ kind: "address", item })}
+ showServer={showServerCol}
+ />
+ ) : chainGroup === "simulator" ? (
+
+ ) : (
+
+
+ ({
+ value: c,
+ label: c,
+ count: chainCounts[c] ?? 0,
+ })),
+ ]}
+ />
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ {filteredRules.length} правил
+
+
+
+ { void toggleRule(id) }}
+ onEdit={openEdit}
+ onDelete={(item) => requestDelete({ kind: "rule", item })}
+ onReorder={canReorder && !togglePending && !deletePending && !movePending && !busy && !historyRestoring && !firewallWriteBlocked ? (activeId, overId) => { void reorderRules(activeId, overId) } : undefined}
+ showServer={showServerCol}
+ />
+
+ )}
+
+
+
+ {[
+ {
+ title: "/ip firewall filter",
+ lines: ["input — трафик на роутер", "forward — транзит", "output — от роутера"],
+ },
+ {
+ title: "/ipv6 firewall filter",
+ lines: ["input / forward / output", "без префикса ip6-", "семейство — отдельный сегмент"],
+ },
+ {
+ title: "Новые матчеры 7.x",
+ lines: [
+ "tls-host=*.google.com",
+ "connection-rate=100/s",
+ "layer7-protocol=...",
+ ],
+ },
+ {
+ title: "Новые действия 7.x",
+ lines: [
+ "fasttrack-connection",
+ "nfqueue (queue-num=0)",
+ "return",
+ "passthrough",
+ ],
+ },
+ ].map((t) => (
+
+
{t.title}
+
+ {t.lines.map((c) => (
+
+ › {c}
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+ { if (!mutationLock.current) setRuleSheetOpen(false) }}
+ returnFocus={() => (editingRule?.id ? document.querySelector(`tr[data-row-id="${CSS.escape(editingRule.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector("[data-firewall-new-rule]")}
+ initialRule={editingRule}
+ chainGroup={sheetChainGroup}
+ family={editingRule?.id ? ruleFamily(editingRule as FirewallRule) : ipFamily}
+ onSave={(payload) => { void saveRule(payload) }}
+ saving={busy}
+ error={formError}
+ />
+
+ { if (!mutationLock.current) setAddrSheetOpen(false) }}
+ returnFocus={() => (editingAddr?.id ? document.querySelector(`[data-firewall-address-id="${CSS.escape(editingAddr.id)}"] button[aria-haspopup="menu"]`) : null) ?? document.querySelector("[data-firewall-add-address]")}
+ initial={editingAddr}
+ family={editingAddr?.id ? addressFamily(editingAddr as AddressListEntry) : ipFamily}
+ onSave={(payload) => { void saveAddr(payload) }}
+ saving={busy}
+ error={formError}
+ />
+
+ setExportOpen(false)}
+ rules={familyRules}
+ />
+
+ {
+ if (!open && !mutationLock.current) { setPendingDelete(null); setDeleteError(null) }
+ }}>
+
+
+ {pendingDelete?.kind === "rule" ? "Удалить правило Firewall?" : "Удалить запись address-list?"}
+
+ {pendingDelete && (pendingDelete.kind === "rule"
+ ? [pendingDelete.item.comment || pendingDelete.item.rosId || pendingDelete.item.id, pendingDelete.item.serverName || pendingDelete.item.serverId, ruleFamily(pendingDelete.item), pendingDelete.item.table, pendingDelete.item.chain].join(" · ")
+ : [pendingDelete.item.address, pendingDelete.item.list, pendingDelete.item.serverName || pendingDelete.item.serverId, addressFamily(pendingDelete.item)].join(" · "))}
+
+ {deletePending && Ожидание результата удаления…
}
+ {deleteError && {deleteError}
}
+
+
+ Отмена
+ { e.preventDefault(); void confirmDelete() }}>Удалить
+
+
+
+
+ { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
+ loadError={historyError}
+ onRetry={() => { void loadRevisions() }}
+ title="История Firewall"
+ itemLabel="объектов"
+ revisions={revisions}
+ loading={historyLoading}
+ restoring={historyRestoring}
+ restoreDisabled={!can("mm:network:write") || !isLive || !writeServer?.enabled || !readTrusted || failures.some(f => f.serverId === historyServerId) || dataLoading || busy || historyRestoring}
+ onRestore={restoreRevision}
+ />
+ >
+ )
+}
+
+function FirewallSource() {
+ const { mode, backendUrl } = useDataSource()
+ return
+}
+
+export default function FirewallPage() {
+ return (
+
+
+
+ )
+}
diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx
new file mode 100644
index 00000000..f7be42b4
--- /dev/null
+++ b/app/(main)/gre/page.tsx
@@ -0,0 +1,994 @@
+"use client"
+
+import { can } from "@/lib/auth"
+
+import { Suspense, useCallback, useEffect, useRef, useMemo, useState } from "react"
+import Link from "next/link"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
+import { PageHeader } from "@/components/page-header"
+import { DataPageCard } from "@/components/data-page-card"
+import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
+import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
+import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
+import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
+import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
+import type { GreTunnel, GreStatus, Server } from "@/lib/data"
+import { useDataSource } from "@/lib/data-source"
+import { generateGreRsc, greExportWarnings } from "@/lib/gre-export"
+import { greResponseSchema } from "@/lib/gre-response"
+import { vxlanServersSchema } from "@/lib/vxlan-response"
+import type { z } from "zod"
+import { requestJson } from "@/shared/api/http-client"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
+import { OpsPanel } from "@/components/ops-panel"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { ConfigHistorySheet } from "@/components/config-history-sheet"
+import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {
+ Sheet, SheetContent, SheetHeader, SheetTitle,
+ SheetDescription, SheetFooter, SheetClose,
+} from "@/components/ui/sheet"
+import {
+ PlusIcon, RefreshCwIcon,
+ LockIcon, ShieldCheckIcon, NetworkIcon,
+ EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
+ Trash2Icon,
+ DatabaseIcon, HistoryIcon,
+} from "lucide-react"
+import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
+import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
+import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
+
+// ─── label maps ─────────────────────────────────────────────────────────────
+
+const STATUS_MAP: Record = {
+ up: { label: "Up", dot: "bg-emerald-500" },
+ degraded: { label: "Degraded", dot: "bg-amber-500" },
+ down: { label: "Down", dot: "bg-red-500" },
+}
+
+function greStatusMeta(status: GreStatus | undefined) {
+ return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
+}
+
+// ─── small ui helpers ────────────────────────────────────────────────────────
+
+// ─── Live API (как на /filters) ─────────────────────────────────────────────
+
+type BackendServer = z.infer[number]
+type InventoryServer = Server & { monitoringUnknown?: boolean }
+
+function makeApiFetch(backendUrl: string) {
+ return async function apiFetch(path: string, init?: RequestInit): Promise {
+ return requestJson(backendUrl, path, init)
+ }
+}
+
+function mapBackendToServer(s: BackendServer): InventoryServer {
+ return {
+ id: String(s.id),
+ name: s.name || s.host,
+ host: s.host,
+ model: "—",
+ os: "—",
+ site: s.site ?? "",
+ country: s.country || "UN",
+ asn: s.asn ?? "",
+ type: s.type ?? "exit-node",
+ enabled: s.enabled,
+ status: s.status ?? "offline",
+ monitoringUnknown: s.status == null,
+ latency: s.latency != null ? Math.round(s.latency) : null,
+ sessions: 0,
+ }
+}
+
+// ─── default form states ─────────────────────────────────────────────────────
+
+const defaultTunnelForm = {
+ name: "", serverId: "", localAddress: "", remoteAddress: "",
+ poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
+ ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
+ mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
+ dscp: "inherit", clampTcpMss: true, allowFastPath: true,
+ showAdvanced: false,
+}
+
+
+type TabFilter = "all" | "up" | "ipsec" | "plain"
+type PageTab = "tunnels" | "pools"
+
+// ════════════════════════════════════════════════════════════════════════════
+function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
+ selectedServerId: string
+ selectedTunnelId: string | null
+ allTunnelsHref: string
+ onSelectServer: (id: string) => void
+}) {
+ const { mode, backendUrl } = useDataSource()
+ const isLive = mode === "live"
+ const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
+
+ const [liveServers, setLiveServers] = useState([])
+ const [liveTunnels, setLiveTunnels] = useState([])
+ const [dataLoading, setDataLoading] = useState(true)
+ const [dataError, setDataError] = useState(null)
+ const [failures, setFailures] = useState["failures"]>([])
+ const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
+ const [hasLoaded, setHasLoaded] = useState(false)
+ const [readReceipt, setReadReceipt] = useState>({})
+ const [responseReceipt, setResponseReceipt] = useState(null)
+ const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
+ const loadEpoch = useRef(0)
+ const mounted = useRef(true)
+ const operationLock = useRef(false)
+ useEffect(() => {
+ mounted.current = true
+ return () => { mounted.current = false; loadEpoch.current += 1 }
+ }, [])
+
+ const [pageTab, setPageTab] = useState("tunnels")
+ const [tabFilter, setTabFilter] = useState("all")
+ const [search, setSearch] = useState("")
+
+ const [tunnelOpen, setTunnelOpen] = useState(false)
+ const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
+ const [editingTunnel, setEditingTunnel] = useState(null)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const formLock = useRef(false)
+ const [formError, setFormError] = useState(null)
+ const toggleLock = useRef(false)
+ const [toggleStatus, setToggleStatus] = useState(null)
+ const [toggleError, setToggleError] = useState(null)
+ const deleteLock = useRef(false)
+ const [deleteError, setDeleteError] = useState(null)
+ const [mutateBusy, setMutateBusy] = useState(false)
+ const [liveStale, setLiveStale] = useState(false)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [revisions, setRevisions] = useState([])
+ const [historyLoading, setHistoryLoading] = useState(false)
+ const [historyError, setHistoryError] = useState(null)
+ const historyEpoch = useRef(0)
+ const [historyRestoring, setHistoryRestoring] = useState(false)
+ const [codePreviewTunnel, setCodePreviewTunnel] = useState