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. Дополнительно */} +
+
+ +
+ + + }>Отмена + + """, + """ + + + + + }>Отмена + {sheetStep > 1 && ( + + )} + {sheetStep < 4 ? ( + + ) : ( + + )} + """, + 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 && ( + + )} + +
+
+ ) +} + +// ─── 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 ( + + ) + })} +
+ + )} +
+ ) +} + +/** Карточки 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 ( + + ) + })} +
+ + )} +
+ ) +} + +/** Карточки объекта для типов со строковым списком (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 ( + + ) + })} +
+ + )} +
+ ) +} + +/** Выбор условия: как объекты — несколько карточек, 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 ( + + ) + })} +
+ + )} +
+ ) +} + +/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */ +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 ( + + ) + } + + 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 ( + + ) + } + + const m = TYPE_META[ruleType] + const Icon = m.Icon + const sub = discreteTargetSubtitle(ruleType, target) + const joined = targets.length > 1 ? targets.join(", ") : target + return ( + + ) +} + +// ─── 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 */} +
+
+ Название правила + +
+ { + 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 ( + + ) + })} +
+
+ + + + {/* Объект и условие — вертикально; после выбора объекта список сворачивается, условия появляются */} +
+ {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 ( + + ) + })} +
+
+ + {/* cooldown */} +
+ Повторять не чаще чем +
+ {COOLDOWNS.map(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 ( + + ) + })} +
+ + Если за выбранное время условие перестанет выполняться (сигнал «отменится»), уведомление не отправится. + Не зависит от cooldown: сначала ждём стабильность, затем проверяем cooldown после фактической отправки. + +
+ +
+ Группа правил (ANY / ALL) + + + 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 ? ( + + ) : null} + {saveError?:null} + {testError?

{testError}

:null} + {testOutcome?

{testOutcome}

:null} + {testReview} + {review} + {saveError?

{saveError}

:null} + +
+
+
+ ) +} + +// ─── 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 ( +
+ + + + + } + /> + +
+
+ + {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 }) => ( + + ))} + +
+ + {/* active count chip */} + + + {/* 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}

+
+ + +
+
+ ))} +
+ + + Группа + + } + 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 неизвестен. Проверьте чат перед повтором: сообщение могло отправиться. Черновик правила не записан.

: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 ( +
+ + + + + + } + /> +
+
+
+

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 ( +
+ + + + + } + /> + +
+
+ {urlError && Не удалось прочитать ссылку{urlError}} + {!isLive &&

Демонстрационные данные. Операции и сохранение недоступны.

} + {!canRead &&

Нет права mm:backups:read

} + {isLive && !canReadServers &&

Каталог устройств недоступен без mm:servers:read.

} + {isLive && canRead &&
+ + {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 + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")} + + + + } + {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(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}} + + 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) => ( + + ))} +
+ } + /> + {selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) &&

Сессия из ссылки скрыта текущими фильтрами.

} + {hasFilters && !urlError && } + +
+
+ ) +} + +// ─── 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 ( +
+ +
+
+
+ {count} +
+ ) + })} + + {/* 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 ( + + + + + } + /> + } + banner={ +
+
+ {TABS.map(t => ( + + ))} +
+
+ } + > +
+ + {unavailableContext && Устройство BGP недоступно в этом каталогеКонтекст {selectedServerId} сохранён. Выберите доступное устройство.} + {selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.} + {/* data source banner */} + {isLive && loading && ( +
+ + Загрузка BGP-сессий… +
+ )} + {isLive && fetchedAt && ( +
+ Ответ каталога получен . + Это время получения браузером, не время измерения RouterOS. + {displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s =>

{s.name}: {readReceipts[s.id] ? : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}

)} +
+ )} + {isLive && (liveError || failures.length > 0) && ( + + + {liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"} + + {liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."} + + + + )} + {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) && ( +
+ Основные + + + + + setIssueCertName(e.target.value)} + placeholder="router-le" + /> + +
+ )} + + {(showAll || step === 2) && ( +
+ Домены + + setIssueCommonName(e.target.value)} + placeholder="vpn.example.com" + /> + + +