feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
@@ -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<string | null>(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"<Field\b", "<FormField", text)
|
||||||
|
text = re.sub(r"</Field>", "</FormField>", text)
|
||||||
|
text = re.sub(r"<Toggle\b", "<FormToggle", text)
|
||||||
|
|
||||||
|
new_table = """ {/* Table */}
|
||||||
|
<Card>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: tabs.map((tab) => ({
|
||||||
|
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} серверов`}
|
||||||
|
/>
|
||||||
|
<ServersDataGrid
|
||||||
|
servers={filtered}
|
||||||
|
isLive={isLive}
|
||||||
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
|
||||||
|
new_table,
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
@@ -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(
|
||||||
|
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* 1. Основные */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>""",
|
||||||
|
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 2. WAN-аплинки (только для home-router) */}
|
||||||
|
{isHomeRouter && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Подключение (API) */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 4. Дополнительно */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
|
<Button className="flex-1" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
|
{sheetStep > 1 && (
|
||||||
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAN step 2: show message when not home router
|
||||||
|
text = text.replace(
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" />
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
""" />
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("stepper patched")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs"
|
||||||
|
|
||||||
|
const messagePath = process.argv[2]
|
||||||
|
|
||||||
|
if (!messagePath) {
|
||||||
|
console.error("validate-commit-message: не указан файл сообщения коммита")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SKIP_COMMIT_MESSAGE_RU === "1") {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(messagePath, "utf8")
|
||||||
|
const subject = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => 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)
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
node "$(dirname "$0")/../.ci/scripts/validate-commit-message.mjs" "$1"
|
||||||
+80
@@ -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
|
||||||
@@ -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 -` из корня проекта.
|
||||||
@@ -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"]
|
||||||
@@ -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`** и **`:<commit-sha>`**; платформа **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/<owner>/<stem>-backend` |
|
||||||
|
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
|
||||||
|
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
|
||||||
|
|
||||||
|
- `<owner>` — первая часть `gitea.repository`, lower case.
|
||||||
|
- `<stem>` — имя репозитория 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` и `:<sha>`.
|
||||||
|
- Первый релиз без тега `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`, `:<sha>`, `:<semver>`.
|
||||||
|
- 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=<user>
|
||||||
|
export REGISTRY_PASSWORD=<token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Скопировать и отредактировать конфиг 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=<user> \
|
||||||
|
-e REGISTRY_PASSWORD=<token> \
|
||||||
|
-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 <image>` → 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/<target_id>`; при занятом 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 версии: тег `:<commit-sha>` из 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).
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
|
actions={mode === "live" ? null : <>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
|
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">ASN</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Автономные системы — источники маршрутов для BGP-фильтров
|
||||||
|
</p>
|
||||||
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
|
</div>
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
|
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
||||||
|
/>
|
||||||
|
{known || loading ? <AsnsDataGrid
|
||||||
|
asns={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт ASN"
|
||||||
|
description="Загрузите CSV или JSON со списком автономных систем"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <Suspense fallback={<p role="status" className="p-5">Загрузка контекста резервных копий…</p>}><BackupsSource /></Suspense> }
|
||||||
|
function BackupsSource() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||||
|
return <BackupsContent key={mode + ":" + backendUrl} />
|
||||||
|
}
|
||||||
|
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<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
|
||||||
|
const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
|
||||||
|
const refreshTrigger = useRef<HTMLButtonElement | null>(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<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
|
||||||
|
if (replacement) return replacement
|
||||||
|
}
|
||||||
|
return refreshTrigger.current
|
||||||
|
}
|
||||||
|
const reader = useRef<AbortController | null>(null)
|
||||||
|
const revision = useRef(0)
|
||||||
|
const alive = useRef(true)
|
||||||
|
const scheduleDirty = useRef(false)
|
||||||
|
const storageDirty = useRef(false)
|
||||||
|
const operationLock = useRef(false)
|
||||||
|
const operationController = useRef<AbortController | null>(null)
|
||||||
|
const downloadControllers = useRef(new Map<string, AbortController>())
|
||||||
|
const settingsLock = useRef(false)
|
||||||
|
const settingsController = useRef<AbortController | null>(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<Backup[]>(isLive ? [] : mockBackups)
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>(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<string | null>(null)
|
||||||
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
|
const [syncBusy, setSyncBusy] = useState(false)
|
||||||
|
const [schedule, setSchedule] = useState<BackupScheduleForm>(defaultSchedule)
|
||||||
|
const [storage, setStorage] = useState<BackupStorageForm>(defaultStorageForm)
|
||||||
|
const [storageIsDirty, setStorageIsDirty] = useState(false)
|
||||||
|
const [savedSchedule, setSavedSchedule] = useState<BackupScheduleSettingsDto | null>(null)
|
||||||
|
const [savedStorage, setSavedStorage] = useState<BackupStorageSettingsDto | null>(null)
|
||||||
|
const [scheduleLastError, setScheduleLastError] = useState<string | null>(null)
|
||||||
|
const [allEnabled, setAllEnabled] = useState(true)
|
||||||
|
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set())
|
||||||
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
|
const [manualStep, setManualStep] = useState(1)
|
||||||
|
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||||
|
const [manualNotes, setManualNotes] = useState("")
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Backup | null>(null)
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
|
|
||||||
|
const setSched = <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => { scheduleDirty.current = true; setSchedule(s => ({ ...s, [k]: v })) }
|
||||||
|
const setStore = <K extends keyof BackupStorageForm>(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<unknown>(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 (
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openCreate(true)}
|
||||||
|
disabled={!canCreate || loading || job.busy}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-4" />
|
||||||
|
Снять со всех
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openCreate(false)}
|
||||||
|
disabled={checkpoint ? !canRead : !canCreate || loading || job.busy}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{checkpoint ? "Задача резервного копирования" : "Новый бэкап"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
|
||||||
|
{!canRead && <p role="status">Нет права mm:backups:read</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
|
||||||
|
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
|
||||||
|
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
|
||||||
|
</div>}
|
||||||
|
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
|
||||||
|
<AlertTitle>{resources[resource].known ? "Последние данные сохранены" : "Данные пока неизвестны"}</AlertTitle>
|
||||||
|
<AlertDescription>{({ backups: "История", servers: "Серверы", schedule: "Расписание", storage: "Хранилище" })[resource]}: {resources[resource].error}</AlertDescription>
|
||||||
|
</Alert>)}
|
||||||
|
|
||||||
|
{isLive && (restoreGuard.checkpoint || restoreGuard.error) && <Alert variant="warning" aria-label="Подтверждение восстановления">
|
||||||
|
<AlertTitle>{opBusy && restoreTarget ? "Импорт ожидает подтверждения" : "Проверьте результат восстановления"}</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-3">
|
||||||
|
<span>{restoreGuard.error || (restoreGuard.checkpoint ? restoreGuard.checkpoint.filename + " → " + restoreGuard.checkpoint.serverName + ". Ответ ещё не подтверждён. Закрытие страницы не отменяет импорт." : "")}</span>
|
||||||
|
<label className="flex items-start gap-2"><Checkbox checked={restoreReviewed} disabled={opBusy || !canWrite} onCheckedChange={value => setRestoreReviewed(value === true)} />Я проверил состояние роутера и понимаю последствия повторного импорта</label>
|
||||||
|
<Button type="button" variant="outline" disabled={!restoreReviewed || opBusy || !canWrite} onClick={() => { if (restoreGuard.clear()) { setRestoreReviewed(false); setOpError(null) } }}>Разрешить новое восстановление</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>}
|
||||||
|
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
|
||||||
|
{(job.error || job.storageError || persistedJobError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError || persistedJobError}</AlertDescription></Alert>}
|
||||||
|
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка бэкапов"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего бэкапов",
|
||||||
|
value: known("backups") ? backupList.length : "—",
|
||||||
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auto",
|
||||||
|
label: "Авто",
|
||||||
|
value: known("backups") ? autoCount : "—",
|
||||||
|
icon: <ClockIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manual",
|
||||||
|
label: "Вручную",
|
||||||
|
value: known("backups") ? manualCount : "—",
|
||||||
|
icon: <PlusIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s3",
|
||||||
|
label: "В S3",
|
||||||
|
value: known("backups") ? s3Count : "—",
|
||||||
|
icon: <CloudIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant={alertVariant}>
|
||||||
|
<ClockIcon />
|
||||||
|
<AlertTitle>
|
||||||
|
{backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{backupJobId
|
||||||
|
? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID."
|
||||||
|
: statusDetail}
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button type="button" variant="ghost" size="xs" onClick={() => setTab("settings")}>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => setTab(String(v) as PageTab)}
|
||||||
|
className="gap-5"
|
||||||
|
>
|
||||||
|
<TabsList variant="line" className="w-fit">
|
||||||
|
<TabsTrigger value="history">История бэкапов</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Настройки</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="history" className="flex flex-col gap-5">
|
||||||
|
{urlError ? null : !canRead ? <p role="status">История недоступна без права чтения</p> : !known("backups") ? <p role="status">{resources.backups.error ? "История резервных копий пока неизвестна" : "Загрузка резервных копий…"}</p> : <BackupsHistory
|
||||||
|
backups={backupList}
|
||||||
|
kindFilter={kindFilter}
|
||||||
|
onKindFilterChange={setKindFilter}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
filters={advancedFilters}
|
||||||
|
onFiltersChange={setAdvancedFilters}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onRestore={backup => { 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}
|
||||||
|
/>}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings">
|
||||||
|
|
||||||
|
<BackupsSettings
|
||||||
|
schedule={schedule}
|
||||||
|
onScheduleChange={setSched}
|
||||||
|
storage={storage}
|
||||||
|
onStorageChange={setStore}
|
||||||
|
savedStorage={savedStorage}
|
||||||
|
servers={liveServers}
|
||||||
|
selectedServers={selectedServers}
|
||||||
|
onToggleServer={(id) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</FramePanel></Frame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupCreateSheet
|
||||||
|
open={manualOpen}
|
||||||
|
onOpenChange={setManualOpen}
|
||||||
|
step={manualStep}
|
||||||
|
onStepChange={setManualStep}
|
||||||
|
servers={liveServers}
|
||||||
|
selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers}
|
||||||
|
onToggle={(id) => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BackupDeleteDialog
|
||||||
|
returnFocus={returnDialogFocus}
|
||||||
|
backup={deleteTarget}
|
||||||
|
busy={opBusy}
|
||||||
|
error={opError}
|
||||||
|
onConfirm={() => void handleDelete()}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
<BackupRestoreDialog
|
||||||
|
returnFocus={returnDialogFocus}
|
||||||
|
backup={restoreTarget}
|
||||||
|
busy={opBusy}
|
||||||
|
blocked={!restoreGuard.canStart || Boolean(restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget))}
|
||||||
|
error={restoreTarget && backupFileMissing(backupList.find(backup => backup.id === restoreTarget.id) ?? restoreTarget) ? "Локальный файл отсутствует. Создайте новую резервную копию." : opError}
|
||||||
|
onConfirm={() => void handleRestore()}
|
||||||
|
onCancel={() => setRestoreTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
|
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<BgpType, { bg: string; text: string }> = {
|
||||||
|
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 (
|
||||||
|
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||||
|
s.bg, s.text, "border-current/20")}>
|
||||||
|
{type === "Unknown" ? "Тип неизвестен" : type}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const fmtNum = fmtBgpNum
|
||||||
|
|
||||||
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BackendServer = ReturnType<typeof bgpServersSchema.parse>[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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{urlError && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>{urlError}<Button variant="outline" size="sm" onClick={reset}>Сбросить фильтры BGP</Button></AlertDescription></Alert>}
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
className="[&>div:first-child]:max-w-full [&>div:first-child]:overflow-x-auto [&>div:first-child_button]:shrink-0 [&>div:first-child_button]:whitespace-nowrap"
|
||||||
|
segmented={{
|
||||||
|
value: stateFilter,
|
||||||
|
onChange: setStateFilter,
|
||||||
|
options: [
|
||||||
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
|
{ value: "Unknown", label: "Неизвестно", count: sessions.filter(s => 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={<QueryFilters fields={fields} query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="IP, AS, описание…"
|
||||||
|
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
|
{(["all", "eBGP", "iBGP", "Unknown"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTypeFilter(t)}
|
||||||
|
aria-pressed={typeFilter === t}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
|
typeFilter === t
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === "all" ? "Все типы" : t === "Unknown" ? "Неизвестно" : t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedSessionId && sessions.some(s => s.id === selectedSessionId) && !filtered.some(s => s.id === selectedSessionId) && <p role="status" className="px-5 py-2 text-xs text-muted-foreground">Сессия из ссылки скрыта текущими фильтрами.</p>}
|
||||||
|
{hasFilters && !urlError && <Button variant="ghost" size="sm" onClick={reset}>Сбросить фильтры BGP</Button>}
|
||||||
|
<BgpSessionsDataGrid sessions={filtered} emptyTitle={urlError ? "Некорректные фильтры BGP" : emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── routers tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
|
const byRouter = useMemo(() => {
|
||||||
|
const map: Record<string, { label: string; site: string; sessions: BgpSession[] }> = {}
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
|
{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 (
|
||||||
|
<Frame key={router.id} dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||||
|
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium leading-none">{router.label}</p>
|
||||||
|
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · {Array.from(new Set(router.sessions.map(s => s.localAs === null ? "AS неизвестна" : "AS" + s.localAs))).join(", ")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{estCnt > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||||
|
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||||
|
✓ {estCnt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{downCnt > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
|
||||||
|
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>
|
||||||
|
⚠ {downCnt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* session rows */}
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{router.sessions.map(s => {
|
||||||
|
const ss = STATE_STYLE[s.state]
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||||
|
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-mono">{s.peerIp}</span>
|
||||||
|
<TypeBadge type={s.type} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
||||||
|
{s.remoteAs !== null && AS_NAMES[s.remoteAs] && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
{s.prefixesRx !== null && s.prefixesRx > 0 && (
|
||||||
|
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
||||||
|
↓ {fmtNum(s.prefixesRx)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className={cn("text-[10px] font-semibold", ss.text)}>{ss.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* footer summary */}
|
||||||
|
{totalRx !== null && totalRx > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||||||
|
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground">
|
||||||
|
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<string, number> = {}
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка префиксов BGP"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "rx",
|
||||||
|
label: "Префиксы",
|
||||||
|
value: fmtNum(totalRx),
|
||||||
|
icon: <DownloadIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
label: "Маршруты",
|
||||||
|
hint: totalActive === null ? "нет данных API" : undefined,
|
||||||
|
value: fmtNum(totalActive),
|
||||||
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ebgp",
|
||||||
|
label: "eBGP",
|
||||||
|
value: sessions.some(s => s.type === "Unknown") ? "—" : ebgpSessions,
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ibgp",
|
||||||
|
label: "iBGP",
|
||||||
|
value: sessions.some(s => s.type === "Unknown") ? "—" : ibgpSessions,
|
||||||
|
icon: <ServerIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||||
|
{/* prefixes by peer — horizontal bar chart */}
|
||||||
|
<OpsPanel title="Топ-8 пиров по полученным префиксам" contentClassName="pt-4 pb-4 px-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{topPeers.map((s, i) => {
|
||||||
|
const pct = ((s.prefixesRx ?? 0) / maxRx) * 100
|
||||||
|
const ss = STATE_STYLE[s.state]
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="flex items-center gap-3">
|
||||||
|
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">{s.remoteAs === null ? "AS неизвестна" : "AS" + s.remoteAs}</span>
|
||||||
|
<TypeBadge type={s.type} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
||||||
|
{fmtNum(s.prefixesRx)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full transition-all", ss.dot)}
|
||||||
|
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{topPeers.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">Недостаточно данных о префиксах для сравнения</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
{/* right column */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* state distribution */}
|
||||||
|
<OpsPanel title="Распределение состояний" contentClassName="pt-4 pb-4 px-5">
|
||||||
|
{stateCounts.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{stateCounts.map(([state, count]) => {
|
||||||
|
const ss = STATE_STYLE[state]
|
||||||
|
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={state} className="flex items-center gap-2">
|
||||||
|
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||||
|
<span className="text-xs w-24">{ss.label}</span>
|
||||||
|
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
{/* eBGP vs iBGP */}
|
||||||
|
<OpsPanel title="eBGP vs iBGP" contentClassName="pt-4 pb-4 px-5">
|
||||||
|
{sessions.length === 0 || sessions.some(s => s.type === "Unknown") ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
|
||||||
|
<TypeBadge type={type} />
|
||||||
|
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
|
||||||
|
style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono w-4 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* active AS list */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-border/60">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{Object.entries(
|
||||||
|
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((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]) => (
|
||||||
|
<div key={as} className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-mono text-muted-foreground">AS{as}</span>
|
||||||
|
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
|
||||||
|
<span className="font-mono tabular-nums">{cnt}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||||||
|
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
|
||||||
|
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
|
||||||
|
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BgpPage() {
|
||||||
|
return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
|
||||||
|
}
|
||||||
|
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<string, string | null>) {
|
||||||
|
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 <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
|
||||||
|
onSelectServer={id => 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<BgpSession[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(isLive)
|
||||||
|
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
const [failures, setFailures] = useState<BgpFailure[]>([])
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false)
|
||||||
|
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
|
||||||
|
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<unknown>(backendUrl, "/api/bgp/catalog"),
|
||||||
|
requestJson<unknown>(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<ServerTileItem[]>(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
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 (
|
||||||
|
<ServerRailLayout
|
||||||
|
preserveSelection
|
||||||
|
items={railItems}
|
||||||
|
selectedId={effectiveServerId}
|
||||||
|
onSelect={onSelectServer}
|
||||||
|
showAll
|
||||||
|
allCount={displayServers.length}
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
banner={
|
||||||
|
<div className="border-b bg-background shrink-0">
|
||||||
|
<div className="flex min-w-0 items-center overflow-x-auto px-4 sm:px-6">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button key={t.id} type="button" aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === t.id
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
|
)}>
|
||||||
|
{t.icon}{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
|
||||||
|
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
|
||||||
|
{/* data source banner */}
|
||||||
|
{isLive && loading && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||||||
|
Загрузка BGP-сессий…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && fetchedAt && (
|
||||||
|
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
|
||||||
|
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
|
||||||
|
Это время получения браузером, не время измерения RouterOS.
|
||||||
|
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && (liveError || failures.length > 0) && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>{liveError ? "Не удалось загрузить BGP" : "Не удалось опросить часть роутеров BGP"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{liveError ?? failures.map(f => f.serverName + ": " + f.error).join("; ")}. {hasLoaded ? "Показаны последние полученные данные; статус отказавших устройств неизвестен." : "Данные пока неизвестны."}
|
||||||
|
<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку BGP">Повторить</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка BGP"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "sessions",
|
||||||
|
label: "Сессий",
|
||||||
|
value: incomplete ? "—" : sessions.length,
|
||||||
|
hint: incomplete ? "неизвестно" : undefined,
|
||||||
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "established",
|
||||||
|
label: "Established",
|
||||||
|
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : established,
|
||||||
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "not-estab",
|
||||||
|
label: "Не поднято",
|
||||||
|
value: incomplete || sessions.some(s => s.state === "Unknown") ? "—" : notEstab,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: notEstab > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prefixes",
|
||||||
|
label: "Префиксы",
|
||||||
|
value: incomplete ? "—" : fmtNum(totalRx),
|
||||||
|
icon: <DownloadIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* alert: not-established sessions */}
|
||||||
|
{!incomplete && notEstab > 0 && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>
|
||||||
|
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Проверьте {sessions.filter(s => s.state !== "Established" && s.state !== "Unknown").map(s => s.peerIp).join(", ")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* tab content */}
|
||||||
|
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
|
||||||
|
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
|
||||||
|
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-4 py-3 text-sm">
|
||||||
|
<AlertCircleIcon className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-destructive">
|
||||||
|
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
{expired.map((c) => c.name).join(", ")} — требуют обновления
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg bg-warning/5 border border-warning/20 px-4 py-3 text-sm">
|
||||||
|
<AlertTriangleIcon className="size-5 text-warning shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-warning">
|
||||||
|
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertPartKpi({
|
||||||
|
displayCerts,
|
||||||
|
expiring,
|
||||||
|
expired,
|
||||||
|
complete,
|
||||||
|
totalKnown,
|
||||||
|
}: {
|
||||||
|
displayCerts: CertificateView[]
|
||||||
|
expiring: CertificateView[]
|
||||||
|
expired: CertificateView[]
|
||||||
|
complete: boolean
|
||||||
|
totalKnown: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка сертификатов"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего",
|
||||||
|
value: totalKnown ? displayCerts.length : "—",
|
||||||
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "valid",
|
||||||
|
label: "Действующих",
|
||||||
|
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
|
||||||
|
icon: <BadgeCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expiring",
|
||||||
|
label: "Истекают",
|
||||||
|
value: complete ? expiring.length : "—",
|
||||||
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
|
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: expiring.length > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expired",
|
||||||
|
label: "Истёкших",
|
||||||
|
value: complete ? expired.length : "—",
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: expired.length > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertPartReference() {
|
||||||
|
return (
|
||||||
|
<OpsPanel
|
||||||
|
title="RouterOS 7 · /certificate — справка CLI"
|
||||||
|
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||||
|
contentClassName="px-5 py-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div key={b.title}>
|
||||||
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||||||
|
{b.title}
|
||||||
|
</p>
|
||||||
|
<pre tabIndex={0} role="region" aria-label={"Команды RouterOS: " + b.title} className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2">
|
||||||
|
{b.lines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{(showAll || step === 1) && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>
|
||||||
|
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||||
|
<select
|
||||||
|
aria-label="Сервер выпуска"
|
||||||
|
value={issueServerId}
|
||||||
|
onChange={(e) => setIssueServerId(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Выбрать сервер…
|
||||||
|
</option>
|
||||||
|
{serverList.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.site})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
aria-label="Имя сертификата"
|
||||||
|
value={issueCertName}
|
||||||
|
onChange={(e) => setIssueCertName(e.target.value)}
|
||||||
|
placeholder="router-le"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 2) && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Домены</SectionTitle>
|
||||||
|
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||||
|
<Input
|
||||||
|
className="font-mono"
|
||||||
|
aria-label="Основной домен"
|
||||||
|
value={issueCommonName}
|
||||||
|
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||||
|
placeholder="vpn.example.com"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="SAN" hint="По одному имени в строке">
|
||||||
|
<textarea
|
||||||
|
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Дополнительные домены"
|
||||||
|
value={issueSans}
|
||||||
|
onChange={(e) => setIssueSans(e.target.value)}
|
||||||
|
placeholder="www.example.com"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">Let's Encrypt · DNS-01 (Cloudflare)</p>
|
||||||
|
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 3) && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Trust store · www</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle ariaLabel="Trust store www" checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Trust store · api</p>
|
||||||
|
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle ariaLabel="Trust store api" checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAll || step === 4) && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||||
|
<p className="font-medium mb-2">Проверьте параметры</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
|
||||||
|
<li>Имя: {issueCertName || "—"}</li>
|
||||||
|
<li>CN: {issueCommonName || "—"}</li>
|
||||||
|
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||||
|
<li>Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.</li>
|
||||||
|
<li>DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertificatesPage() {
|
||||||
|
return <Suspense fallback={<p role="status">Загрузка контекста сертификатов…</p>}><CertificatesSource /></Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertificatesSource() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status" className="p-5">Загрузка настроек источника…</p>
|
||||||
|
return <CertificatesContent key={mode + ":" + backendUrl} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertificatesContent() {
|
||||||
|
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
||||||
|
const isLive = prefsHydrated && mode === "live"
|
||||||
|
const liveReady = isLive && backendStatus === true
|
||||||
|
|
||||||
|
const params = useSearchParams()
|
||||||
|
const context = readCertificatesUrl(params)
|
||||||
|
const search = context.search
|
||||||
|
const statusFilter = context.status
|
||||||
|
const selectedServerId = context.server
|
||||||
|
const urlError = context.error
|
||||||
|
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||||
|
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||||
|
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||||
|
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||||
|
const canRead = can("mm:certificates:read")
|
||||||
|
const canWrite = can("mm:certificates:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
|
||||||
|
const { snapshots, loading, error: loadError, serverError } = catalog
|
||||||
|
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
|
||||||
|
|
||||||
|
const [issueOpen, setIssueOpen] = useState(false)
|
||||||
|
const [issueStep, setIssueStep] = useState(1)
|
||||||
|
const [issueServerId, setIssueServerId] = useState("")
|
||||||
|
const [issueCertName, setIssueCertName] = useState("")
|
||||||
|
const [issueCommonName, setIssueCommonName] = useState("")
|
||||||
|
const [issueSans, setIssueSans] = useState("")
|
||||||
|
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||||
|
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||||
|
|
||||||
|
|
||||||
|
const displayCerts = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
|
if (isLive) return Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
|
||||||
|
return routerCertificates.map(mockToDto)
|
||||||
|
}, [prefsHydrated, isLive, snapshots])
|
||||||
|
|
||||||
|
const displayServers = isLive ? serverList : mockServers
|
||||||
|
|
||||||
|
const scopedCerts = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
|
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||||
|
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||||
|
}, [displayCerts, selectedServerId, urlError])
|
||||||
|
|
||||||
|
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
|
||||||
|
|
||||||
|
const serverById = useMemo(() => {
|
||||||
|
const map = new Map<string, Server>()
|
||||||
|
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||||
|
return map
|
||||||
|
}, [isLive, serverList])
|
||||||
|
|
||||||
|
const loadLive = catalog.reload
|
||||||
|
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
|
||||||
|
const issueBusy = issue.busy
|
||||||
|
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
|
||||||
|
|
||||||
|
const expiring = useMemo(
|
||||||
|
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||||
|
[scopedCerts],
|
||||||
|
)
|
||||||
|
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||||
|
|
||||||
|
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
|
||||||
|
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
|
||||||
|
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return scopedCerts.filter((c) => {
|
||||||
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.commonName.toLowerCase().includes(q) ||
|
||||||
|
c.issuedBy.toLowerCase().includes(q) ||
|
||||||
|
c.sans.some((s) => s.includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [scopedCerts, search, statusFilter])
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
await loadLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIssue() {
|
||||||
|
if (issue.checkpoint) { await issue.run(); return }
|
||||||
|
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
|
||||||
|
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
|
||||||
|
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
|
||||||
|
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
|
||||||
|
}
|
||||||
|
function startNewIssue() {
|
||||||
|
if (!issue.reset()) return
|
||||||
|
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={certRailItems}
|
||||||
|
selectedId={selectedServerId}
|
||||||
|
onSelect={setSelectedServerId}
|
||||||
|
showAll
|
||||||
|
allCount={displayServers.length}
|
||||||
|
preserveSelection
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!isLive || loading || (!canRead && !canReadServers)}
|
||||||
|
onClick={() => {
|
||||||
|
void handleRefresh()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
|
||||||
|
<UploadIcon className="size-4" />
|
||||||
|
Импорт
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||||
|
onClick={() => {
|
||||||
|
setIssueStep(issue.checkpoint ? 4 : 1)
|
||||||
|
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||||
|
setIssueOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5 min-w-0">
|
||||||
|
{!isLive && <p role="status" className="text-xs text-muted-foreground">Моковые данные</p>}
|
||||||
|
{urlError && <div role="alert" aria-label="Контекст сертификатов">{urlError} <Button variant="outline" size="sm" onClick={() => writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст</Button></div>}
|
||||||
|
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) && <p role="alert">Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.</p>}
|
||||||
|
{isLive && backendStatus === false && (
|
||||||
|
<div className="rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
|
||||||
|
Backend недоступен — live-операции отключены.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadError && isLive && (
|
||||||
|
<div role="alert" aria-label="Каталог сертификатов" className="[overflow-wrap:anywhere] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||||
|
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && serverError && <p role="alert" aria-label="Серверы сертификатов" className="[overflow-wrap:anywhere]">{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}</p>}
|
||||||
|
{isLive && !canRead && <p role="status">Нет права mm:certificates:read.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Нет права mm:servers:read. Выбор устройства для выпуска недоступен.</p>}
|
||||||
|
{isLive && catalog.collectedAt && <p className="text-xs text-muted-foreground">Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}</p>}
|
||||||
|
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot => <div key={snapshot.latest.serverId}>
|
||||||
|
{snapshot.latest.error && <p role="alert" aria-label={"Сертификаты сервера " + snapshot.latest.serverId} className="[overflow-wrap:anywhere]">{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}</p>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{(expiring.length > 0 || expired.length > 0) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||||||
|
{expiring.length > 0 && <CertPartAlertExpiring expiring={expiring} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!classificationsKnown && <p role="status" className="text-xs text-muted-foreground">Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.</p>}
|
||||||
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} complete={classificationsKnown} totalKnown={scopeKnown} />
|
||||||
|
|
||||||
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||||
|
|
||||||
|
<CertificateAcmeSettings backendUrl={backendUrl} liveReady={liveReady} canRead={canRead} canWrite={canWrite} />
|
||||||
|
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||||||
|
filterControl={<div role="group" aria-label="Статус сертификатов" className="w-full sm:w-auto max-w-full overflow-x-auto"><SegmentedControl className="whitespace-nowrap"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "Все" },
|
||||||
|
{ value: "valid", label: "Действующие" },
|
||||||
|
{ value: "expired", label: "Истёкшие" },
|
||||||
|
{ value: "revoked", label: "Отозванные" },
|
||||||
|
{ value: "unknown", label: "Неизвестные" },
|
||||||
|
]}
|
||||||
|
/></div>}
|
||||||
|
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
|
||||||
|
/>
|
||||||
|
{urlError ? <p role="status" className="p-5">Исправьте контекст ссылки</p> : isLive && scopedCerts.length === 0 && !scopeKnown ? <p role="status" className="p-5">{loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}</p> : <CertificatesDataGrid
|
||||||
|
certificates={filtered}
|
||||||
|
serverMap={serverById}
|
||||||
|
emptyTitle={search || statusFilter !== "all" ? "Нет совпадений" : "Нет сертификатов"}
|
||||||
|
emptyDescription={search || statusFilter !== "all" ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}
|
||||||
|
isLoading={isLive && loading && displayCerts.length === 0}
|
||||||
|
/>}
|
||||||
|
</DataPageCard>
|
||||||
|
|
||||||
|
<CertPartReference />
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||||
|
<SheetContent side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="px-6 pt-3 text-sm min-w-0 [overflow-wrap:anywhere]">
|
||||||
|
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
|
||||||
|
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
|
||||||
|
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
|
||||||
|
</div>
|
||||||
|
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
{[
|
||||||
|
{ step: 1, title: "Основные" },
|
||||||
|
{ step: 2, title: "Домены" },
|
||||||
|
{ step: 3, title: "Импорт" },
|
||||||
|
{ step: 4, title: "Проверка" },
|
||||||
|
].map(({ step, title }, i, arr) => (
|
||||||
|
<StepperItem key={step} step={step}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>{step}</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">{title}</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
{i < arr.length - 1 && <StepperSeparator />}
|
||||||
|
</StepperItem>
|
||||||
|
))}
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
{[1, 2, 3, 4].map((s) => (
|
||||||
|
<StepperContent key={s} value={s}>
|
||||||
|
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
|
||||||
|
<CertPartIssueForm
|
||||||
|
step={s as 1 | 2 | 3 | 4}
|
||||||
|
serverList={displayServers}
|
||||||
|
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
|
||||||
|
setIssueServerId={setIssueServerId}
|
||||||
|
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
|
||||||
|
setIssueCertName={setIssueCertName}
|
||||||
|
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
|
||||||
|
setIssueCommonName={setIssueCommonName}
|
||||||
|
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
|
||||||
|
setIssueSans={setIssueSans}
|
||||||
|
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
|
||||||
|
setIssueTrustWww={setIssueTrustWww}
|
||||||
|
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
|
||||||
|
setIssueTrustApi={setIssueTrustApi}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</StepperContent>
|
||||||
|
))}
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
|
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||||
|
{issue.checkpoint ? "Закрыть" : "Отмена"}
|
||||||
|
</SheetClose>
|
||||||
|
{issueStep > 1 && (
|
||||||
|
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{issueStep < 4 ? (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||||
|
onClick={() => setIssueStep((s) => s + 1)}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||||
|
onClick={() => { void handleIssue() }}
|
||||||
|
>
|
||||||
|
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useMemo } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import {
|
||||||
|
CommunitiesDataGrid,
|
||||||
|
type CommunityRow,
|
||||||
|
TYPE_LABELS,
|
||||||
|
ACTION_LABELS,
|
||||||
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||||
|
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { filters } from "@/lib/data"
|
||||||
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Community = CommunityRow
|
||||||
|
type CommType = CommunityRow["type"]
|
||||||
|
|
||||||
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COMMUNITIES: Community[] = [
|
||||||
|
{
|
||||||
|
id: "c1", value: "65001:100", name: "youtube-bypass",
|
||||||
|
description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes",
|
||||||
|
type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842,
|
||||||
|
action: "local-pref", actionValue: 200, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c2", value: "65001:200", name: "streaming-eu",
|
||||||
|
description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS",
|
||||||
|
type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614,
|
||||||
|
action: "local-pref", actionValue: 180, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c3", value: "65001:300", name: "cdn-bypass",
|
||||||
|
description: "Обход CDN-провайдеров через прямые пиринговые IX-точки",
|
||||||
|
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218,
|
||||||
|
action: "local-pref", actionValue: 210, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c4", value: "65002:100", name: "cdn-secondary",
|
||||||
|
description: "Резервный CDN-путь при деградации основного",
|
||||||
|
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842,
|
||||||
|
action: "metric", actionValue: 50, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c5", value: "65001:400", name: "office-direct",
|
||||||
|
description: "Office 365 / Teams — прямой выход без туннелирования",
|
||||||
|
type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882,
|
||||||
|
action: "permit", enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c6", value: "65001:500", name: "gaming-ll",
|
||||||
|
description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)",
|
||||||
|
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212,
|
||||||
|
action: "local-pref", actionValue: 250, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c7", value: "65002:200", name: "gaming-ll-backup",
|
||||||
|
description: "Резервный путь для gaming-low-latency",
|
||||||
|
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412,
|
||||||
|
action: "metric", actionValue: 80, enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c8", value: "65007:999", name: "school-block",
|
||||||
|
description: "Блокировка соцсетей для школьного сегмента LAN",
|
||||||
|
type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412,
|
||||||
|
action: "deny", enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c9", value: "no-export", name: "no-export",
|
||||||
|
description: "Не объявлять маршрут за пределы AS (RFC 1997)",
|
||||||
|
type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0,
|
||||||
|
action: "permit", enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c10", value: "no-advertise", name: "no-advertise",
|
||||||
|
description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)",
|
||||||
|
type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0,
|
||||||
|
action: "permit", enabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CommunitiesPage() {
|
||||||
|
const { sourceKey } = useEvoBGP()
|
||||||
|
return <CommunitiesContent key={sourceKey} />
|
||||||
|
}
|
||||||
|
function CommunitiesContent() {
|
||||||
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||||
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
|
const [copying, setCopying] = useState(false)
|
||||||
|
const [copyError, setCopyError] = useState<string | null>(null)
|
||||||
|
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
|
||||||
|
useEffect(() => {
|
||||||
|
const state = copyRequest.current; state.active = true
|
||||||
|
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
|
||||||
|
}, [])
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const listData = useMemo((): Community[] => {
|
||||||
|
if (mode === "mock") return COMMUNITIES
|
||||||
|
if (loading && !snapshot) return []
|
||||||
|
return snapshot?.communities ?? []
|
||||||
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return listData.filter(c => {
|
||||||
|
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
|
||||||
|
const matchT = typeFilter === "all" || c.type === typeFilter
|
||||||
|
return matchQ && matchT
|
||||||
|
})
|
||||||
|
}, [search, typeFilter, listData])
|
||||||
|
|
||||||
|
const handleCopy = async (value: string) => {
|
||||||
|
const state = copyRequest.current
|
||||||
|
if (!state.active || state.pending) return
|
||||||
|
state.pending = true; const revision = ++state.revision
|
||||||
|
if (state.timer) clearTimeout(state.timer)
|
||||||
|
const current = () => state.active && state.revision === revision
|
||||||
|
setCopying(true); setCopied(null); setCopyError(null)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
if (!current()) return
|
||||||
|
setCopied(value)
|
||||||
|
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
|
||||||
|
} catch {
|
||||||
|
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
|
||||||
|
} finally {
|
||||||
|
state.pending = false
|
||||||
|
if (current()) setCopying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||||
|
actions={mode === "live" ? null : <Button size="sm">
|
||||||
|
<PlusIcon className="size-4" />Добавить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<EvoBgpCatalogStatus />
|
||||||
|
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||||
|
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||||
|
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка communities"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего communities",
|
||||||
|
value: known ? String(listData.length) : "—",
|
||||||
|
icon: <TagIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "Активных",
|
||||||
|
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||||
|
icon: <CheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "standard",
|
||||||
|
label: "Стандартных",
|
||||||
|
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
||||||
|
icon: <TagIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
label: "Использует фильтры",
|
||||||
|
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
|
||||||
|
icon: <FilterIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
||||||
|
{/* ── main table ── */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* toolbar */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-[180px]">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||||
|
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||||
|
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||||
|
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||||
|
{t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* list */}
|
||||||
|
<DataPageCard>
|
||||||
|
{known ? <CommunitiesDataGrid
|
||||||
|
communities={filtered}
|
||||||
|
selectedId={selected?.id}
|
||||||
|
copiedValue={copied}
|
||||||
|
copying={copying}
|
||||||
|
onSelect={row => setSelectedId(row.id)}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── detail panel ── */}
|
||||||
|
{selected ? (
|
||||||
|
<OpsPanel
|
||||||
|
className="h-fit sticky top-0"
|
||||||
|
title={<span className="font-mono">{selected.value}</span>}
|
||||||
|
description={selected.name}
|
||||||
|
headerRight={
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||||
|
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||||
|
selected.enabled
|
||||||
|
? "bg-emerald-500/10 text-emerald-600"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{selected.enabled ? "Активен" : "Выключен"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
["Тип", TYPE_LABELS[selected.type]],
|
||||||
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||||
|
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
||||||
|
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{k}</span>
|
||||||
|
<span className="text-xs font-medium">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.filterIds.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<FilterIcon className="size-3" />Использующие фильтры
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selected.filterIds.map(fid => (
|
||||||
|
<span key={fid} className="text-[11px] bg-muted px-2 py-0.5 rounded font-mono">
|
||||||
|
{filterName(fid)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-2 border-t flex flex-col gap-2">
|
||||||
|
<Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
||||||
|
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||||
|
Скопировать значение
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
) : (
|
||||||
|
<Frame dense className="w-full h-fit">
|
||||||
|
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||||
|
<TagIcon className="size-8 text-muted-foreground/30" />
|
||||||
|
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
|
import type { RouterContainer, Server } from "@/lib/data"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
|
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
|
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
||||||
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { userApiError } from "@/shared/api/users"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
|
type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
||||||
|
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"] | null
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): ContainerServer {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? null,
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusConfig(status: ContainerView["status"]) {
|
||||||
|
return {
|
||||||
|
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
||||||
|
running: {
|
||||||
|
dot: "bg-success",
|
||||||
|
badge: "bg-success/10 text-success border-success/20",
|
||||||
|
label: "Работает",
|
||||||
|
},
|
||||||
|
stopped: {
|
||||||
|
dot: "bg-muted-foreground",
|
||||||
|
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||||
|
label: "Остановлен",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
dot: "bg-destructive",
|
||||||
|
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
|
label: "Ошибка",
|
||||||
|
},
|
||||||
|
}[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
||||||
|
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
||||||
|
const formats = useMemo(() => [{
|
||||||
|
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
||||||
|
code: selection ? JSON.stringify(selection, null, 2) : "",
|
||||||
|
}], [selection])
|
||||||
|
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||||
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||||
|
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||||||
|
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||||
|
formats={formats} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerCard({
|
||||||
|
container,
|
||||||
|
server,
|
||||||
|
live,
|
||||||
|
busy,
|
||||||
|
onExport,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onRestart,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
container: ContainerView
|
||||||
|
server?: ContainerServer
|
||||||
|
live: boolean
|
||||||
|
busy: boolean
|
||||||
|
onExport: () => void
|
||||||
|
onStart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
const cfg = statusConfig(container.status)
|
||||||
|
const canMutate = live && Boolean(container.rosId) && !busy
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense className="w-full overflow-hidden">
|
||||||
|
<FramePanel className="p-0 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||||||
|
<span className="font-mono font-semibold text-sm truncate">{container.name}</span>
|
||||||
|
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0", cfg.badge)}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={
|
||||||
|
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||||||
|
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
} />
|
||||||
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
|
{container.status === "running" ? (
|
||||||
|
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||||
|
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
||||||
|
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||||||
|
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||||
|
<Trash2Icon className="size-4" />Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||||||
|
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{server && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
<Flag code={server.country} size={12} />
|
||||||
|
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||||
|
{container.uptime && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
||||||
|
<span>{container.uptime}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{container.cpu !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">CPU </span>
|
||||||
|
<span className={cn("font-mono font-medium",
|
||||||
|
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||||||
|
{container.cpu}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{container.memMb !== undefined && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">RAM </span>
|
||||||
|
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{container.interfaces.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{container.interfaces.map((i) => (
|
||||||
|
<span key={i} className="text-[10px] font-mono px-1.5 py-0.5 bg-muted border rounded text-muted-foreground">
|
||||||
|
{i}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{container.mounts.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{container.mounts.map((m, idx) => (
|
||||||
|
<div key={idx} className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground/40">→</span>
|
||||||
|
<span>{m.dst}</span>
|
||||||
|
{m.src && <><span className="text-muted-foreground/40">←</span><span>{m.src}</span></>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{container.comment && (
|
||||||
|
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||||
|
function ContainersSource() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||||
|
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
function ContainersContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:dashboard:read")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
const canWrite = can("mm:servers:write")
|
||||||
|
const params = useSearchParams()
|
||||||
|
const context = readContainersUrl(params)
|
||||||
|
const search = context.search
|
||||||
|
const statusFilter = context.status
|
||||||
|
const selectedServerId = context.server
|
||||||
|
const urlError = context.error
|
||||||
|
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||||
|
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||||
|
}
|
||||||
|
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||||
|
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||||
|
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||||
|
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
||||||
|
const [state, setState] = useState<ContainersState | null>(null)
|
||||||
|
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||||
|
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||||
|
const deleteReturnLabel = useRef<string | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||||||
|
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [deleteTarget, busyId])
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null)
|
||||||
|
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||||
|
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
const [serversError, setServersError] = useState<string | null>(null)
|
||||||
|
const [readAt, setReadAt] = useState<number | null>(null)
|
||||||
|
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||||
|
const loadController = useRef<AbortController | null>(null)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return false
|
||||||
|
loadController.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
loadController.current = controller
|
||||||
|
const revision = ++loadRevision.current
|
||||||
|
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
||||||
|
setLoading(true)
|
||||||
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
|
let stateComplete = !canRead
|
||||||
|
const tasks = await Promise.allSettled([
|
||||||
|
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||||
|
stateComplete = result.servers.every(node => node.availability !== "error")
|
||||||
|
if (current()) {
|
||||||
|
setState(result); setReadAt(Date.now()); setLiveError(null)
|
||||||
|
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
||||||
|
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
||||||
|
}
|
||||||
|
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||||
|
const result = serverListSchema.parse(payload)
|
||||||
|
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
||||||
|
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
||||||
|
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||||
|
])
|
||||||
|
if (current()) setLoading(false)
|
||||||
|
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
||||||
|
}, [isLive, backendUrl, canRead, canReadServers])
|
||||||
|
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||||
|
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||||
|
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||||
|
const stale = isLive && !!liveError
|
||||||
|
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||||
|
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||||
|
const scoped = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
|
}, [displayContainers, effectiveServerId])
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status ?? "unknown",
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayContainers, state, isLive])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return scoped.filter((c) => {
|
||||||
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.image.toLowerCase().includes(q) ||
|
||||||
|
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [search, statusFilter, scoped, serverById])
|
||||||
|
|
||||||
|
const running = scoped.filter((c) => c.status === "running").length
|
||||||
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
|
const statusKnown = known && scoped.every(container => container.status !== "unknown")
|
||||||
|
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
|
||||||
|
const deleteStopped = currentDeleteTarget?.status === "stopped"
|
||||||
|
|
||||||
|
async function mutate(c: ContainerView, action: ContainerAction) {
|
||||||
|
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
|
||||||
|
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
||||||
|
try {
|
||||||
|
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
if (!current()) return
|
||||||
|
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
||||||
|
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
||||||
|
setOperationNotice(message)
|
||||||
|
if (action === "remove") {
|
||||||
|
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
toast.success("Команда подтверждена API.")
|
||||||
|
const refreshed = await loadLive()
|
||||||
|
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (!current()) return
|
||||||
|
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
||||||
|
setCommandNeedsRead(uncertain)
|
||||||
|
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={railItems}
|
||||||
|
selectedId={effectiveServerId}
|
||||||
|
onSelect={setSelectedServerId}
|
||||||
|
showAll
|
||||||
|
preserveSelection
|
||||||
|
allCount={displayServers.length}
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||||||
|
|
||||||
|
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||||||
|
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||||||
|
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
|
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||||
|
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||||
|
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
||||||
|
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
||||||
|
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||||
|
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
||||||
|
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка контейнеров"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего",
|
||||||
|
value: known ? scoped.length : "—",
|
||||||
|
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "running",
|
||||||
|
label: "Работает",
|
||||||
|
value: statusKnown ? running : "—",
|
||||||
|
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stopped",
|
||||||
|
label: "Остановлен",
|
||||||
|
value: statusKnown ? stopped : "—",
|
||||||
|
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "errors",
|
||||||
|
label: "Ошибок",
|
||||||
|
value: statusKnown ? errors : "—",
|
||||||
|
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
||||||
|
iconClassName: "text-destructive",
|
||||||
|
variant: errors > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||||
|
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
||||||
|
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||||
|
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||||
|
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
||||||
|
<button key={s}
|
||||||
|
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||||
|
statusFilter === s
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}>
|
||||||
|
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
|
<BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
||||||
|
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||||
|
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<ContainerCard
|
||||||
|
key={c.id}
|
||||||
|
container={c}
|
||||||
|
server={serverById[c.serverId]}
|
||||||
|
live={isLive && !!nodeFresh(c.serverId)}
|
||||||
|
busy={!!busyId}
|
||||||
|
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
||||||
|
onStart={() => { void mutate(c, "start") }}
|
||||||
|
onStop={() => { void mutate(c, "stop") }}
|
||||||
|
onRestart={() => { void mutate(c, "restart") }}
|
||||||
|
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Установка пакета",
|
||||||
|
lines: [
|
||||||
|
"# Скачать пакет container:",
|
||||||
|
"# mikrotik.com → Software",
|
||||||
|
"",
|
||||||
|
"/system/package/install",
|
||||||
|
" container",
|
||||||
|
"",
|
||||||
|
"# Перезагрузить:",
|
||||||
|
"/system/reboot",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Создать контейнер",
|
||||||
|
lines: [
|
||||||
|
"# veth интерфейс:",
|
||||||
|
"/interface/veth/add \\",
|
||||||
|
" name=veth-nginx \\",
|
||||||
|
" address=172.17.0.2/24 \\",
|
||||||
|
" gateway=172.17.0.1",
|
||||||
|
"",
|
||||||
|
"# Контейнер:",
|
||||||
|
"/container/add \\",
|
||||||
|
" remote-image=nginx:alpine \\",
|
||||||
|
" interface=veth-nginx",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Управление",
|
||||||
|
lines: [
|
||||||
|
"# Список:",
|
||||||
|
"/container/print",
|
||||||
|
"",
|
||||||
|
"# Запуск:",
|
||||||
|
"/container/start 0",
|
||||||
|
"",
|
||||||
|
"# Остановка:",
|
||||||
|
"/container/stop 0",
|
||||||
|
"",
|
||||||
|
"# Логи:",
|
||||||
|
"/container/shell 0",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].map((b) => (
|
||||||
|
<div key={b.title}>
|
||||||
|
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||||
|
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||||
|
{b.lines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
||||||
|
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
||||||
|
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>}
|
||||||
|
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
BellIcon,
|
||||||
|
CableIcon,
|
||||||
|
FilterIcon,
|
||||||
|
GitMergeIcon,
|
||||||
|
HardDriveIcon,
|
||||||
|
HeartPulseIcon,
|
||||||
|
MapIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { QuickActionGrid } from "@/components/reui-kit/quick-action-grid"
|
||||||
|
import { AttentionQueue } from "@/components/reui-kit/attention-queue"
|
||||||
|
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||||
|
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||||
|
import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events-timeline"
|
||||||
|
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
||||||
|
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { fmtRate } from "@/lib/fmt-rate"
|
||||||
|
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
||||||
|
|
||||||
|
function fmtIntRu(n: number): string {
|
||||||
|
return n.toLocaleString("ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttentionRows({ rows }: { rows: AttentionRow[] }) {
|
||||||
|
const [expanded,setExpanded]=useState(false)
|
||||||
|
const [visible,setVisible]=useState(55)
|
||||||
|
function renderRow(row: AttentionRow) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={row.id}
|
||||||
|
href={row.href}
|
||||||
|
className="hover:bg-muted/40 focus-visible:ring-ring flex min-w-0 items-start justify-between gap-2 rounded-md px-1 py-1.5 focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 wrap-anywhere text-sm font-medium">{row.title}</span>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={row.tone === "warning" ? "warning-light" : "destructive-light"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{row.hint}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{rows.slice(0, 5).map(renderRow)}
|
||||||
|
{rows.length > 5 && (
|
||||||
|
<details onToggle={event=>{setExpanded(event.currentTarget.open);if(!event.currentTarget.open)setVisible(55)}}>
|
||||||
|
<summary className="cursor-pointer rounded-md px-1 py-2 text-xs text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring">Ещё {rows.length - 5}</summary>
|
||||||
|
{expanded && rows.slice(5,visible).map(renderRow)}
|
||||||
|
{expanded && rows.length>visible && <Button type="button" variant="ghost" size="sm" onClick={()=>setVisible(count=>Math.min(count+50,rows.length))}>Показать ещё {Math.min(50,rows.length-visible)}</Button>}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const dash = useDashboardLive()
|
||||||
|
const offline = dash.offlineCount
|
||||||
|
const health = dash.probeDown + dash.probeWarn
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
{dash.isLive && <DashboardDataStatus sources={dash.sources} refreshing={dash.refreshing} onRefresh={dash.retry} />}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка дашборда"
|
||||||
|
isLoading={dash.loading}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "fleet",
|
||||||
|
label: "Серверы онлайн",
|
||||||
|
value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
|
||||||
|
hint: !dash.availability.servers ? "нет данных" : !dash.isLive
|
||||||
|
? "демо"
|
||||||
|
: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
|
||||||
|
? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
|
||||||
|
: dash.totalServers > 0
|
||||||
|
? "все в сети"
|
||||||
|
: "нет серверов",
|
||||||
|
href: "/servers",
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "overlay",
|
||||||
|
label: "Туннели работают",
|
||||||
|
value: dash.availability.overlay ? String(dash.overlay.up) : "—",
|
||||||
|
hint: !dash.availability.overlay ? "не все источники доступны" : !dash.reliable.overlay ? "Последний успешный снимок; не все источники обновлены" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей",
|
||||||
|
href: "/gre",
|
||||||
|
icon: <CableIcon />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
variant: dash.overlay.down > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bgp",
|
||||||
|
label: "Префиксы BGP",
|
||||||
|
value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—",
|
||||||
|
hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных",
|
||||||
|
href: "/bgp",
|
||||||
|
icon: <GitMergeIcon />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "health",
|
||||||
|
label: "Проверки с проблемами",
|
||||||
|
value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
|
||||||
|
hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
|
||||||
|
href: "/uptime",
|
||||||
|
icon: <BellIcon />,
|
||||||
|
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: health > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section aria-labelledby="dashboard-attention-title" className="flex min-w-0 flex-col gap-2">
|
||||||
|
<h2 id="dashboard-attention-title" className="text-sm font-semibold">Требует внимания</h2>
|
||||||
|
<AttentionQueue
|
||||||
|
className="lg:grid-cols-3"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "nodes",
|
||||||
|
title: "Серверы",
|
||||||
|
icon: ServerIcon,
|
||||||
|
count: dash.attentionServers.length,
|
||||||
|
countVariant: "warning-light",
|
||||||
|
emptyTitle: dash.reliable.servers ? (dash.totalServers > 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionServers} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "overlay",
|
||||||
|
title: "Туннели",
|
||||||
|
icon: CableIcon,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
count: dash.attentionOverlay.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionOverlay} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "probes",
|
||||||
|
title: "Проверки",
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
iconClassName: "text-destructive",
|
||||||
|
count: dash.attentionProbes.length,
|
||||||
|
countVariant: "destructive-light",
|
||||||
|
emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено",
|
||||||
|
emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.",
|
||||||
|
children: <AttentionRows rows={dash.attentionProbes} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_1fr]">
|
||||||
|
<OpsPanel
|
||||||
|
title="Задержка до серверов"
|
||||||
|
description={
|
||||||
|
dash.latency.kind === "mock" || dash.latency.kind === "live"
|
||||||
|
? dash.latency.subtitle
|
||||||
|
: dash.latency.kind === "loading"
|
||||||
|
? "Загрузка…"
|
||||||
|
: "Нет серии RTT"
|
||||||
|
}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.latency.kind === "loading" && (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
)}
|
||||||
|
{dash.latency.kind === "empty" && (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">{dash.latency.message}</p>
|
||||||
|
)}
|
||||||
|
{(dash.latency.kind === "mock" || dash.latency.kind === "live") && (
|
||||||
|
<LatencyChart series={dash.latency.series} labels={dash.latency.labels} />
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Последние события"
|
||||||
|
description="Система и BGP"
|
||||||
|
headerRight={
|
||||||
|
<Link href="/alerts" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DashboardEventsTimeline
|
||||||
|
events={dash.recentEvents}
|
||||||
|
loading={dash.eventsLoading}
|
||||||
|
error={dash.eventsError}
|
||||||
|
/>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<OpsPanel
|
||||||
|
title="Пропускная способность"
|
||||||
|
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
||||||
|
headerRight={<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>Трафик →</Link>}
|
||||||
|
contentClassName="px-3 pb-3"
|
||||||
|
>
|
||||||
|
{dash.loading && !dash.traffic ? (
|
||||||
|
<div className="bg-muted/50 h-[220px] w-full animate-pulse rounded-md" />
|
||||||
|
) : !dash.traffic ? (
|
||||||
|
<p className="text-muted-foreground px-4 py-10 text-center text-sm">
|
||||||
|
Нет данных трафика за последний час.{" "}
|
||||||
|
<Link href="/traffic" className="underline underline-offset-2">
|
||||||
|
Открыть трафик
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-2 py-3 text-xs">
|
||||||
|
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
|
||||||
|
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
|
||||||
|
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
|
||||||
|
</div>
|
||||||
|
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InternetPathPanel
|
||||||
|
model={dash.internetPath}
|
||||||
|
loading={dash.internetPathLoading}
|
||||||
|
error={dash.isLive ? dash.internetPathError : null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Активные пробы"
|
||||||
|
description={
|
||||||
|
<span className={dash.error && dash.isLive ? "text-destructive" : undefined}>{dash.probesSubtitle}</span>
|
||||||
|
}
|
||||||
|
headerRight={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||||
|
Мониторинг →
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
contentClassName="px-0 pb-0"
|
||||||
|
>
|
||||||
|
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
||||||
|
<EmptyState
|
||||||
|
className="py-10"
|
||||||
|
title={dash.availability.probes ? "Нет проб на дашборде" : "Данные проб недоступны"}
|
||||||
|
description={dash.availability.probes ? "Включите пробу и отметьте ★ на странице мониторинга." : "Повторите обновление данных."}
|
||||||
|
action={
|
||||||
|
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
Открыть мониторинг
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DashboardActiveProbesDataGrid
|
||||||
|
probes={dash.activeProbes}
|
||||||
|
catalog={dash.servers}
|
||||||
|
isLoading={dash.probesLoading}
|
||||||
|
emptyDescription="Нет проб с звездой на дашборде."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<QuickActionGrid
|
||||||
|
title="Быстрые переходы"
|
||||||
|
description="Другие разделы управления сетью"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "traffic",
|
||||||
|
title: "Трафик",
|
||||||
|
description: "Приём, передача и потоки IPFIX",
|
||||||
|
href: "/traffic",
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "map",
|
||||||
|
title: "Карта сети",
|
||||||
|
description: "Топология GRE и WAN",
|
||||||
|
href: "/network-map",
|
||||||
|
icon: <MapIcon />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
title: "Фильтры",
|
||||||
|
description: "Правила обхода и списки",
|
||||||
|
href: "/filters",
|
||||||
|
icon: <FilterIcon />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "firewall",
|
||||||
|
title: "Firewall",
|
||||||
|
description: "Цепочки filter / nat",
|
||||||
|
href: "/firewall",
|
||||||
|
icon: <ShieldIcon />,
|
||||||
|
iconClassName: "text-warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "backups",
|
||||||
|
title: "Бэкапы",
|
||||||
|
description: "Снапшоты и расписание",
|
||||||
|
href: "/backups",
|
||||||
|
icon: <HardDriveIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bgp",
|
||||||
|
title: "BGP",
|
||||||
|
description: "Сессии и префиксы",
|
||||||
|
href: "/bgp",
|
||||||
|
icon: <GitMergeIcon />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||||
|
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function DomainsPage() {
|
||||||
|
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const useEvoCatalog = mode === "live"
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (mode === "mock") return mockDomains
|
||||||
|
if (loading && !snapshot) return []
|
||||||
|
return snapshot?.domains ?? []
|
||||||
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return rows
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.domain.toLowerCase().includes(q) ||
|
||||||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
|
r.filter.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
|
actions={mode === "live" ? null : <>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
|
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Домены из каталога для BGP-фильтрации
|
||||||
|
</p>
|
||||||
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
|
</div>
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по домену…"
|
||||||
|
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
||||||
|
/>
|
||||||
|
{known || loading ? <DomainsDataGrid
|
||||||
|
domains={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт доменов"
|
||||||
|
description="Загрузите CSV или JSON со списком доменов"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,994 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useRef, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||||
|
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
|
||||||
|
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
|
||||||
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||||
|
import type { GreTunnel, GreStatus, Server } from "@/lib/data"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { generateGreRsc, greExportWarnings } from "@/lib/gre-export"
|
||||||
|
import { greResponseSchema } from "@/lib/gre-response"
|
||||||
|
import { vxlanServersSchema } from "@/lib/vxlan-response"
|
||||||
|
import type { z } from "zod"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import {
|
||||||
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import {
|
||||||
|
PlusIcon, RefreshCwIcon,
|
||||||
|
LockIcon, ShieldCheckIcon, NetworkIcon,
|
||||||
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
DatabaseIcon, HistoryIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||||
|
up: { label: "Up", dot: "bg-emerald-500" },
|
||||||
|
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||||
|
down: { label: "Down", dot: "bg-red-500" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function greStatusMeta(status: GreStatus | undefined) {
|
||||||
|
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── small ui helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BackendServer = z.infer<typeof vxlanServersSchema>[number]
|
||||||
|
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||||
|
|
||||||
|
function makeApiFetch(backendUrl: string) {
|
||||||
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestJson<T>(backendUrl, path, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendToServer(s: BackendServer): InventoryServer {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "offline",
|
||||||
|
monitoringUnknown: s.status == null,
|
||||||
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── default form states ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const defaultTunnelForm = {
|
||||||
|
name: "", serverId: "", localAddress: "", remoteAddress: "",
|
||||||
|
poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
|
||||||
|
ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
|
||||||
|
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
|
||||||
|
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
|
||||||
|
showAdvanced: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type TabFilter = "all" | "up" | "ipsec" | "plain"
|
||||||
|
type PageTab = "tunnels" | "pools"
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
|
||||||
|
selectedServerId: string
|
||||||
|
selectedTunnelId: string | null
|
||||||
|
allTunnelsHref: string
|
||||||
|
onSelectServer: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
|
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||||
|
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||||
|
const [dataLoading, setDataLoading] = useState(true)
|
||||||
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
|
const [failures, setFailures] = useState<z.infer<typeof greResponseSchema>["failures"]>([])
|
||||||
|
const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false)
|
||||||
|
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||||
|
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||||
|
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
|
||||||
|
const loadEpoch = useRef(0)
|
||||||
|
const mounted = useRef(true)
|
||||||
|
const operationLock = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true
|
||||||
|
return () => { mounted.current = false; loadEpoch.current += 1 }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||||
|
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||||
|
const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
|
||||||
|
const [editingTunnel, setEditingTunnel] = useState<GreTunnel | null>(null)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
|
||||||
|
const formLock = useRef(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const toggleLock = useRef(false)
|
||||||
|
const [toggleStatus, setToggleStatus] = useState<string | null>(null)
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null)
|
||||||
|
const deleteLock = useRef(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [mutateBusy, setMutateBusy] = useState(false)
|
||||||
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||||
|
const [exportContext, setExportContext] = useState<{ servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||||
|
|
||||||
|
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||||
|
|
||||||
|
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||||||
|
setTForm((f) => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
|
const loadLive = useCallback(async (afterOperation = false) => {
|
||||||
|
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
|
||||||
|
const epoch = ++loadEpoch.current
|
||||||
|
setDataLoading(true)
|
||||||
|
try {
|
||||||
|
const [serversPayload, tunnelsPayload] = await Promise.all([
|
||||||
|
apiFetch<unknown>("/api/servers"), apiFetch<unknown>("/api/gre/tunnels"),
|
||||||
|
])
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
const inventory = vxlanServersSchema.safeParse(serversPayload)
|
||||||
|
const response = greResponseSchema.safeParse(tunnelsPayload)
|
||||||
|
if (!inventory.success || !response.success) throw new Error("Некорректный ответ загрузки GRE")
|
||||||
|
const failedIds = new Set(response.data.failures.map(f => f.serverId))
|
||||||
|
const enabledIds = new Set(inventory.data.filter(s => s.enabled).map(s => String(s.id)))
|
||||||
|
setLiveServers(inventory.data.map(mapBackendToServer))
|
||||||
|
setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||||
|
setFailures(response.data.failures)
|
||||||
|
setHasLoaded(true)
|
||||||
|
const at = new Date().toISOString()
|
||||||
|
setResponseReceipt(at)
|
||||||
|
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||||
|
setDataError(null)
|
||||||
|
setLiveStale(false)
|
||||||
|
} catch (e) {
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
|
setLiveStale(true)
|
||||||
|
} finally {
|
||||||
|
if (epoch === loadEpoch.current) setDataLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, apiFetch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveTunnels([])
|
||||||
|
setDataError(null)
|
||||||
|
setLiveStale(false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayTunnels = isLive ? liveTunnels : mockGreTunnels
|
||||||
|
const displayServers = isLive ? liveServers : mockServers
|
||||||
|
const displayPools = useMemo(
|
||||||
|
() => (isLive ? [] : mockGrePools),
|
||||||
|
[isLive],
|
||||||
|
)
|
||||||
|
|
||||||
|
const greRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
|
||||||
|
}))
|
||||||
|
), [displayServers, displayTunnels, isLive])
|
||||||
|
|
||||||
|
const scopedTunnels = useMemo(() => {
|
||||||
|
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === selectedServerId && t.id === selectedTunnelId)
|
||||||
|
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
|
||||||
|
return displayTunnels.filter((t) => t.serverId === selectedServerId)
|
||||||
|
}, [displayTunnels, selectedServerId, selectedTunnelId])
|
||||||
|
|
||||||
|
const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0]
|
||||||
|
const selectedTunnelError = isLive && (dataError !== null || failedServerIds.includes(selectedServerId))
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
||||||
|
const mutationsLocked = !isLive || !can("mm:network:write") || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring
|
||||||
|
function canManage(serverId: string) {
|
||||||
|
return !mutationsLocked && !failedServerIds.includes(serverId) && liveServers.some(s => s.id === serverId && s.enabled)
|
||||||
|
}
|
||||||
|
function beginOperation(serverId: string) {
|
||||||
|
if (!mounted.current || operationLock.current || !canManage(serverId)) return false
|
||||||
|
operationLock.current = true
|
||||||
|
loadEpoch.current += 1
|
||||||
|
setMutateBusy(true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function endOperation() {
|
||||||
|
operationLock.current = false
|
||||||
|
if (mounted.current) setMutateBusy(false)
|
||||||
|
}
|
||||||
|
const canCreate = selectedServerId === ALL_SERVERS_ID ? liveServers.some(s => canManage(s.id)) : canManage(selectedServerId)
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
||||||
|
|
||||||
|
const loadRevisions = useCallback(async () => {
|
||||||
|
if (!isLive || !historyServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<unknown>(
|
||||||
|
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
|
)
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setRevisions(parseConfigRevisions(res, historyServerId, "gre"))
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
|
} finally {
|
||||||
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = async (id: string) => {
|
||||||
|
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
|
||||||
|
if (!historyServerId || !beginOperation(historyServerId)) throw new Error("Устройство не готово к восстановлению GRE")
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ ok: boolean }>(
|
||||||
|
`/api/gre/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
|
||||||
|
)
|
||||||
|
if (!mounted.current) return
|
||||||
|
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||||||
|
setWriteReceipt({ serverId: historyServerId, at: new Date().toISOString() })
|
||||||
|
toast.success("Версия применена на роутер")
|
||||||
|
await loadLive(true)
|
||||||
|
if (mounted.current) await loadRevisions()
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) throw err
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setHistoryRestoring(false)
|
||||||
|
endOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelWriteBody(form: typeof defaultTunnelForm) {
|
||||||
|
return {
|
||||||
|
serverId: form.serverId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
localAddress: form.localAddress.trim() || undefined,
|
||||||
|
remoteAddress: form.remoteAddress.trim(),
|
||||||
|
localInnerIp: form.localInnerIp.trim(),
|
||||||
|
remoteInnerIp: form.remoteInnerIp.trim(),
|
||||||
|
comment: form.comment || undefined,
|
||||||
|
enabled: form.enabled,
|
||||||
|
mtu: form.mtu,
|
||||||
|
keepaliveInterval: form.keepaliveInterval,
|
||||||
|
keepaliveRetries: form.keepaliveRetries,
|
||||||
|
dscp: form.dscp,
|
||||||
|
clampTcpMss: form.clampTcpMss,
|
||||||
|
allowFastPath: form.ipsecEnabled ? false : form.allowFastPath,
|
||||||
|
ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTunnel() {
|
||||||
|
if (formLock.current || mutationsLocked) return
|
||||||
|
setFormError(null)
|
||||||
|
if (!isLive) {
|
||||||
|
toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (liveStale) {
|
||||||
|
toast.error("Роутер недоступен — изменения заблокированы")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) {
|
||||||
|
setFormError("Заполните имя, сервер и удалённый адрес")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) {
|
||||||
|
setFormError("Для IPsec нужен PSK не короче 8 символов")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (![tForm.keepaliveInterval, tForm.keepaliveRetries].every(value => Number.isInteger(value) && value >= 1 && value <= 4294967295)) {
|
||||||
|
setFormError("Keepalive и число попыток: целые значения от 1 до 4294967295. Ноль не поддерживается этим API.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return }
|
||||||
|
formLock.current = true
|
||||||
|
try {
|
||||||
|
if (tunnelMode === "edit" && editingTunnel) {
|
||||||
|
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...tunnelWriteBody(tForm),
|
||||||
|
rosId: editingTunnel.id,
|
||||||
|
name: editingTunnel.name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!mounted.current) return
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля")
|
||||||
|
setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() })
|
||||||
|
toast.success(`Туннель ${tForm.name} обновлён`)
|
||||||
|
} else {
|
||||||
|
const result = await apiFetch<unknown>("/api/gre/tunnels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(tunnelWriteBody(tForm)),
|
||||||
|
})
|
||||||
|
if (!mounted.current) return
|
||||||
|
const confirmed = typeof result === "object" && result !== null && "name" in result && result.name === tForm.name.trim() && (
|
||||||
|
("ok" in result && result.ok === true) ||
|
||||||
|
("id" in result && typeof result.id === "string" && result.id.length > 0 &&
|
||||||
|
"serverId" in result && String(result.serverId) === tForm.serverId &&
|
||||||
|
"remoteAddress" in result && result.remoteAddress === tForm.remoteAddress.trim() &&
|
||||||
|
"enabled" in result && result.enabled === tForm.enabled)
|
||||||
|
)
|
||||||
|
if (!confirmed) throw new Error("Сервер не подтвердил создание туннеля")
|
||||||
|
setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() })
|
||||||
|
toast.success(`Туннель ${tForm.name} создан`)
|
||||||
|
}
|
||||||
|
setTunnelOpen(false)
|
||||||
|
setEditingTunnel(null)
|
||||||
|
await loadLive(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель")
|
||||||
|
} finally {
|
||||||
|
formLock.current = false
|
||||||
|
endOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTunnel(t: GreTunnel) {
|
||||||
|
if (toggleLock.current || !beginOperation(t.serverId)) return
|
||||||
|
toggleLock.current = true
|
||||||
|
setToggleError(null)
|
||||||
|
setToggleStatus(`Ожидание изменения ${t.name}…`)
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: t.serverId,
|
||||||
|
rosId: t.id,
|
||||||
|
name: t.name,
|
||||||
|
enabled: !t.enabled,
|
||||||
|
remoteAddress: t.remoteAddress,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!mounted.current) return
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля")
|
||||||
|
setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() })
|
||||||
|
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
|
||||||
|
await loadLive(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`)
|
||||||
|
} finally {
|
||||||
|
toggleLock.current = false
|
||||||
|
if (mounted.current) setToggleStatus(null)
|
||||||
|
endOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteTunnel() {
|
||||||
|
const t = pendingDelete
|
||||||
|
if (!t || deleteLock.current || !beginOperation(t.serverId)) return
|
||||||
|
deleteLock.current = true
|
||||||
|
setDeleteError(null)
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }),
|
||||||
|
})
|
||||||
|
if (!mounted.current) return
|
||||||
|
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля")
|
||||||
|
setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() })
|
||||||
|
toast.success(`Удалён ${t.name}`)
|
||||||
|
setPendingDelete(null)
|
||||||
|
await loadLive(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted.current) setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель")
|
||||||
|
} finally {
|
||||||
|
deleteLock.current = false
|
||||||
|
endOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateTunnel() {
|
||||||
|
if (isLive && !canCreate) return
|
||||||
|
setFormError(null)
|
||||||
|
setTunnelMode("create")
|
||||||
|
setEditingTunnel(null)
|
||||||
|
setTForm({
|
||||||
|
...defaultTunnelForm,
|
||||||
|
serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId,
|
||||||
|
})
|
||||||
|
setTunnelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTunnel(t: GreTunnel) {
|
||||||
|
if (isLive && !canManage(t.serverId)) return
|
||||||
|
setFormError(null)
|
||||||
|
setTunnelMode("edit")
|
||||||
|
setEditingTunnel(t)
|
||||||
|
setTForm({
|
||||||
|
...defaultTunnelForm,
|
||||||
|
name: t.name,
|
||||||
|
serverId: t.serverId,
|
||||||
|
localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress,
|
||||||
|
remoteAddress: t.remoteAddress,
|
||||||
|
poolId: t.poolId === "live" ? "" : t.poolId,
|
||||||
|
localInnerIp: t.localInnerIp,
|
||||||
|
remoteInnerIp: t.remoteInnerIp,
|
||||||
|
comment: t.comment,
|
||||||
|
enabled: t.enabled,
|
||||||
|
ipsecEnabled: !!t.ipsec,
|
||||||
|
ipsecSecret: t.ipsec?.secret ?? "",
|
||||||
|
mtu: t.mtu,
|
||||||
|
keepaliveInterval: t.keepaliveInterval,
|
||||||
|
keepaliveRetries: t.keepaliveRetries,
|
||||||
|
dscp: String(t.dscp),
|
||||||
|
clampTcpMss: t.clampTcpMss,
|
||||||
|
allowFastPath: t.allowFastPath,
|
||||||
|
})
|
||||||
|
setTunnelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomplete = isLive && (!hasLoaded || !!dataError || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.includes(selectedServerId)))
|
||||||
|
const unknownServerIds = useMemo(() => new Set(isLive ? dataError ? displayTunnels.map(t => t.serverId) : failedServerIds : []), [isLive, dataError, displayTunnels, failedServerIds])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return scopedTunnels.filter((t) => {
|
||||||
|
if (tabFilter === "up" && (t.status !== "up" || unknownServerIds.has(t.serverId))) return false
|
||||||
|
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||||
|
if (tabFilter === "plain" && t.ipsec) return false
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.remoteAddress.includes(q) ||
|
||||||
|
t.localInnerIp.includes(q) ||
|
||||||
|
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [tabFilter, search, scopedTunnels, serverById, unknownServerIds])
|
||||||
|
|
||||||
|
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
|
const ipsecCount = scopedTunnels.filter((t) => t.ipsec).length
|
||||||
|
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
|
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
|
||||||
|
|
||||||
|
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||||
|
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||||
|
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||||
|
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||||
|
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||||
|
]
|
||||||
|
|
||||||
|
function openCodePreview(t: GreTunnel) {
|
||||||
|
setCodePreviewTunnel(t)
|
||||||
|
setExportContext({ servers: serverById, at: readReceipt[t.serverId] ?? null, stale: isLive && (!hasLoaded || !!dataError || failedServerIds.includes(t.serverId)), mock: !isLive })
|
||||||
|
}
|
||||||
|
const scopeReceipt = selectedServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[selectedServerId]
|
||||||
|
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU")
|
||||||
|
|
||||||
|
const greExportCode = useMemo(
|
||||||
|
() => (codePreviewTunnel ? generateGreRsc(codePreviewTunnel, exportContext?.servers ?? {}) : ""),
|
||||||
|
[codePreviewTunnel, exportContext],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={greRailItems}
|
||||||
|
selectedId={selectedServerId}
|
||||||
|
preserveSelection
|
||||||
|
onSelect={onSelectServer}
|
||||||
|
showAll
|
||||||
|
allCount={displayServers.length}
|
||||||
|
loading={isLive && dataLoading && displayServers.length === 0}
|
||||||
|
banner={isLive && !can("mm:network:write") ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации недоступно.</p> : undefined}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || dataLoading || mutateBusy || historyRestoring}
|
||||||
|
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
disabled={!isLive || !historyServerId || dataLoading || mutateBusy || historyRestoring}
|
||||||
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy || (isLive && !canCreate)}>
|
||||||
|
<PlusIcon className="size-4" />Добавить туннель
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{selectedTunnelId !== null && (
|
||||||
|
<Alert variant={selectedTunnelError || ((!isLive || !dataLoading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="GRE-туннель по ссылке">
|
||||||
|
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран GRE-туннель: " + selectedTunnel.name : "GRE-туннель по ссылке"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="wrap-anywhere">{isLive && dataLoading ? "Загрузка туннеля…" : selectedTunnelError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · " + selectedTunnel.remoteAddress + ". Список ограничен выбранным туннелем; сводка относится к этому срезу." : "Туннель не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && (dataError || failures.length > 0) && (
|
||||||
|
<Alert variant="warning" role="alert">
|
||||||
|
<AlertTitle>{dataError ? "Не удалось загрузить GRE" : "Не удалось опросить часть роутеров"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{hasLoaded ? "Показаны последние полученные данные; актуальность отказавших устройств не подтверждена." : "Данные пока неизвестны. Пустой список не означает отсутствие туннелей."}</p>
|
||||||
|
<p className="wrap-anywhere">{dataError ?? failures.map(f => (f.serverName ?? f.serverId) + ": " + f.error).join("; ")}</p>
|
||||||
|
<Button variant="outline" size="sm" disabled={dataLoading || mutateBusy || historyRestoring} onClick={() => { void loadLive() }}>Повторить загрузку GRE</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка GRE"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "tunnels",
|
||||||
|
label: "Туннелей",
|
||||||
|
value: incomplete ? "—" : scopedTunnels.length,
|
||||||
|
hint: incomplete ? "неизвестно" : undefined,
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "up",
|
||||||
|
label: "Активно",
|
||||||
|
value: incomplete ? "—" : upCount,
|
||||||
|
hint: incomplete ? "неизвестно" : undefined,
|
||||||
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ipsec",
|
||||||
|
label: "С IPsec",
|
||||||
|
value: incomplete ? "—" : ipsecCount,
|
||||||
|
hint: incomplete ? "неизвестно" : undefined,
|
||||||
|
icon: <LockIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pools",
|
||||||
|
label: "IP-пулов",
|
||||||
|
value: isLive ? "—" : displayPools.length,
|
||||||
|
hint: isLive ? "нет данных API" : "моковые данные",
|
||||||
|
icon: <DatabaseIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div role="group" aria-label="Свежесть GRE" className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span data-source-time="read">{isLive ? selectedServerId === ALL_SERVERS_ID ? "Ответ каталога: " : "Опрос устройства: " : "Моковые данные · "}{isLive ? scopeReceipt ? <time dateTime={scopeReceipt} title="Время получения ответа браузером; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно" : "не получены с RouterOS"}</span>
|
||||||
|
{isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && <span data-source-time="write">API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{formatReceipt(writeReceipt.at)}</time></span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toggleStatus && <p role="status" className="text-sm text-muted-foreground">{toggleStatus}</p>}
|
||||||
|
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
|
||||||
|
|
||||||
|
{/* Page tabs */}
|
||||||
|
<div role="group" aria-label="Раздел GRE" className="flex items-center gap-1 border-b">
|
||||||
|
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
|
||||||
|
<button key={tab} aria-pressed={pageTab === tab} onClick={() => setPageTab(tab)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
||||||
|
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tunnels ── */}
|
||||||
|
{pageTab === "tunnels" && (
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: tabFilter,
|
||||||
|
onChange: setTabFilter,
|
||||||
|
options: tunnelTabs.map((t) => ({
|
||||||
|
value: t.value,
|
||||||
|
label: t.label,
|
||||||
|
count: incomplete ? undefined : t.count,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, IP…"
|
||||||
|
countLabel={incomplete ? `${filtered.length} сохранённых строк · сводка неизвестна` : `${filtered.length} туннелей`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GreTunnelsDataGrid
|
||||||
|
tunnels={filtered}
|
||||||
|
servers={displayServers}
|
||||||
|
pools={displayPools}
|
||||||
|
onCodePreview={openCodePreview}
|
||||||
|
onEdit={openEditTunnel}
|
||||||
|
onToggle={(t) => { void toggleTunnel(t) }}
|
||||||
|
onDelete={setPendingDelete}
|
||||||
|
mutationsLocked={mutationsLocked}
|
||||||
|
canManage={canManage}
|
||||||
|
unknownServerIds={unknownServerIds}
|
||||||
|
emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined}
|
||||||
|
emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── IP Pools ── */}
|
||||||
|
{pageTab === "pools" && (
|
||||||
|
<DataPageCard>
|
||||||
|
{isLive ? (
|
||||||
|
<div role="status" className="p-5 text-sm text-muted-foreground">API GRE возвращает интерфейсы и назначенные IP. Каталог пулов, CIDR и свободная ёмкость не предоставляются; управление пулами здесь недоступно.</div>
|
||||||
|
) : <>
|
||||||
|
<DataPageToolbarFrame className="justify-between"><span className="text-sm text-muted-foreground">Моковый каталог · только просмотр</span></DataPageToolbarFrame>
|
||||||
|
<GrePoolsDataGrid pools={displayPools} />
|
||||||
|
|
||||||
|
<div className="border-t px-5 py-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{displayPools.map((pool) => {
|
||||||
|
const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id)
|
||||||
|
return (
|
||||||
|
<div key={pool.id}>
|
||||||
|
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{poolTunnels.map((t, tunnelIndex) => (
|
||||||
|
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||||
|
<span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
|
||||||
|
<span className="font-mono font-medium">{t.name}</span>
|
||||||
|
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||||
|
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</DataPageCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RouterOS reference */}
|
||||||
|
<OpsPanel title="Справка GRE" contentClassName="px-5 py-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс и локальный IP выбранного туннеля. Удалённую сторону и маршруты настройте отдельно. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE" target="_blank" rel="noreferrer" className="underline underline-offset-2">Параметры GRE в документации MikroTik</a></p>
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||||
|
<CodeExportSheet
|
||||||
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||||
|
open={!!codePreviewTunnel}
|
||||||
|
onClose={() => setCodePreviewTunnel(null)}
|
||||||
|
title={codePreviewTunnel?.name ?? "GRE"}
|
||||||
|
description={(codePreviewTunnel ? greExportWarnings(codePreviewTunnel).join(" ") + " " : "") + (exportContext?.mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${exportContext?.at ? formatReceipt(exportContext.at) : "время получения неизвестно"} · ${exportContext?.stale ? "актуальность не подтверждена" : "последний успешный опрос"}`)}
|
||||||
|
formats={[
|
||||||
|
{
|
||||||
|
id: "rsc",
|
||||||
|
label: "RouterOS",
|
||||||
|
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||||
|
code: greExportCode,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
beforeCode={
|
||||||
|
codePreviewTunnel ? (
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`size-1.5 rounded-full ${exportContext?.stale ? "bg-muted-foreground" : greStatusMeta(codePreviewTunnel.status).dot}`} />
|
||||||
|
{exportContext?.stale ? "Неизвестно" : greStatusMeta(codePreviewTunnel.status).label}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span>{exportContext?.servers[codePreviewTunnel.serverId]?.name}</span>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||||
|
{" → "}
|
||||||
|
{codePreviewTunnel.remoteAddress}
|
||||||
|
</span>
|
||||||
|
{codePreviewTunnel.ipsec ? (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="flex items-center gap-1 text-success">
|
||||||
|
<LockIcon className="size-3" />
|
||||||
|
IPsec · PSK
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||||
|
<Sheet open={tunnelOpen} onOpenChange={v => { if (!formLock.current) setTunnelOpen(v) }}>
|
||||||
|
<SheetContent showCloseButton={!mutateBusy} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
|
||||||
|
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
<fieldset disabled={mutateBusy} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>
|
||||||
|
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||||
|
<Input className="font-mono" placeholder="gre-msk-spb" aria-label="Имя интерфейса" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Сервер (MikroTik)" required>
|
||||||
|
<select aria-label="Сервер (MikroTik)" value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
|
<option value="" disabled>Выбрать сервер…</option>
|
||||||
|
{displayServers.map((s) => <option key={s.id} value={s.id} disabled={isLive && !canManage(s.id)}>{s.name}{s.site ? ` (${s.site})` : ""}{isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Комментарий">
|
||||||
|
<Input placeholder="Описание туннеля" aria-label="Комментарий" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Включён</span>
|
||||||
|
<FormToggle ariaLabel="Туннель включён" checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Эндпоинты</SectionTitle>
|
||||||
|
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
||||||
|
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||||||
|
<Input className="font-mono" placeholder="203.0.113.1" aria-label="Удалённый адрес" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Внутренний IP</SectionTitle>
|
||||||
|
{!isLive && <p className="text-xs text-muted-foreground">Моковые пулы доступны только для просмотра.</p>}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<FormField label="Локальный IP" hint="/ip address на этом конце">
|
||||||
|
<Input className="font-mono" aria-label="Локальный IP" placeholder="IPv4/IPv6 с префиксом" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Удалённый IP" hint="/ip address на другом конце">
|
||||||
|
<Input className="font-mono" aria-label="Удалённый IP" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>IPsec</SectionTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Включить IPsec</p>
|
||||||
|
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle ariaLabel="Включить IPsec" checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tForm.ipsecEnabled && (
|
||||||
|
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||||||
|
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||||
|
aria-label="Пароль (PSK)" placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||||||
|
<button type="button" aria-label={tForm.ipsecShowSecret ? "Скрыть PSK" : "Показать PSK"} onClick={() => setT("ipsecShowSecret", !tForm.ipsecShowSecret)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
|
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<p className="text-xs text-muted-foreground">Применяется только PSK через ipsec-secret. Алгоритмы IKE, proposal и PFS задаются настройками RouterOS; эта форма их не изменяет.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => setT("showAdvanced", !tForm.showAdvanced)}
|
||||||
|
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{tForm.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
|
Дополнительно
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</button>
|
||||||
|
{tForm.showAdvanced && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<FormField label="MTU" hint="По умолч. 1476">
|
||||||
|
<Input aria-label="MTU" type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Keepalive, с" hint="Целое число, минимум 1 с">
|
||||||
|
<Input aria-label="Keepalive, с" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Попытки">
|
||||||
|
<Input aria-label="Попытки" type="number" min={1} max={4294967295} step={1} className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="DSCP">
|
||||||
|
<select aria-label="DSCP" value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
|
<option value="inherit">inherit</option>
|
||||||
|
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{[
|
||||||
|
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||||
|
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Ускоренный путь обработки пакетов; при IPsec выключен" },
|
||||||
|
].map(({ key, label, desc }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle ariaLabel={label} checked={key === "allowFastPath" && tForm.ipsecEnabled ? false : tForm[key] as boolean} disabled={key === "allowFastPath" && tForm.ipsecEnabled} onChange={(v) => setT(key, v)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mutateBusy && <p role="status" className="px-6 text-sm text-muted-foreground">Ожидание результата сохранения…</p>}
|
||||||
|
{formError && <p role="alert" className="px-6 text-sm text-destructive">{formError}</p>}
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" disabled={mutateBusy} />}>Отмена</SheetClose>
|
||||||
|
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy || (isLive && !canManage(tForm.serverId))}>
|
||||||
|
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
|
title="История GRE"
|
||||||
|
itemLabel="туннелей"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
restoreDisabled={!can("mm:network:write") || !historyServerId || !canManage(historyServerId)}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingDelete
|
||||||
|
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
|
||||||
|
: null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
{mutateBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
|
||||||
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={mutateBusy}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" disabled={!pendingDelete || !canManage(pendingDelete.serverId)} onClick={(e) => { e.preventDefault(); void confirmDeleteTunnel() }}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GreRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
|
const tunnelId = params.get("tunnel")
|
||||||
|
const rest = new URLSearchParams(params.toString())
|
||||||
|
rest.delete("tunnel")
|
||||||
|
const allTunnelsHref = "/gre" + (rest.size ? "?" + rest.toString() : "")
|
||||||
|
return <GrePageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||||||
|
const next = new URLSearchParams(rest)
|
||||||
|
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||||
|
else next.set("server", id)
|
||||||
|
router.push("/gre" + (next.size ? "?" + next.toString() : ""))
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GrePage() {
|
||||||
|
return <Suspense fallback={<div role="status">Загрузка GRE…</div>}><GreRoute /></Suspense>
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"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 { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
|
import { ipRanges as mockIpRanges } 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 IpRangesPage() {
|
||||||
|
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 mockIpRanges
|
||||||
|
if (loading && !snapshot) return []
|
||||||
|
return snapshot?.ipRanges ?? []
|
||||||
|
}, [mode, loading, snapshot])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return rows
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.cidr.toLowerCase().includes(q) ||
|
||||||
|
r.asn.toLowerCase().includes(q) ||
|
||||||
|
r.country.toLowerCase().includes(q) ||
|
||||||
|
r.filter.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
|
actions={mode === "live" ? null : <>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
|
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
CIDR-блоки из каталога для сетевых фильтров
|
||||||
|
</p>
|
||||||
|
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||||
|
</div>
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
|
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
||||||
|
/>
|
||||||
|
{known || loading ? <IpRangesDataGrid
|
||||||
|
ipRanges={filtered}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
|
pagination={useEvoCatalog}
|
||||||
|
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||||
|
/> : <EvoBgpCatalogUnavailable />}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт IP-диапазонов"
|
||||||
|
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
|
import { DataSourceProvider } from "@/lib/data-source"
|
||||||
|
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
|
const SKIP_TO_CONTENT_CLASS =
|
||||||
|
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<DataSourceProvider>
|
||||||
|
<EvoBGPProvider>
|
||||||
|
<SidebarProvider
|
||||||
|
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||||
|
>
|
||||||
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
|
К содержимому
|
||||||
|
</a>
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</SidebarInset>
|
||||||
|
<CommandPalette />
|
||||||
|
<ReleaseNotesModal />
|
||||||
|
</SidebarProvider>
|
||||||
|
</EvoBGPProvider>
|
||||||
|
</DataSourceProvider>
|
||||||
|
</AuthGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function MainLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 p-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||||
|
<Skeleton className="h-96 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||||
|
export default function LogsPage() { return <LogsWorkspace /> }
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Suspense } from "react"
|
||||||
|
import { McpWorkspace } from "@/components/mcp/mcp-workspace"
|
||||||
|
|
||||||
|
export default function McpPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6">Загрузка MCP…</p>}><McpWorkspace /></Suspense>
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,852 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useMemo, useState, Suspense } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { readRecursiveUrl, patchRecursiveUrl, type RecursiveUrlPatch, type RecursiveUrlState } from "@/lib/recursive-route-url-state"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import {
|
||||||
|
RecursiveRoutesDataGrid,
|
||||||
|
type RecursiveRouteGroup,
|
||||||
|
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||||
|
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
|
||||||
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { StatusDot } from "@/components/status-dot"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
|
import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: "jump-host" | "exit-node" | "home-router"
|
||||||
|
site: string
|
||||||
|
country: string
|
||||||
|
asn: string
|
||||||
|
enabled: boolean
|
||||||
|
status: "online" | "offline" | "degraded" | null
|
||||||
|
latency: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNTRY_OPTIONS = [
|
||||||
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||||
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||||
|
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
|
||||||
|
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
|
||||||
|
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
|
||||||
|
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
|
||||||
|
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
|
||||||
|
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
|
||||||
|
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
|
||||||
|
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
|
||||||
|
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
|
||||||
|
{ code: "NO", label: "Норвегия" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type RouteGroup = RecursiveRouteGroup
|
||||||
|
|
||||||
|
function makeApiFetch(backendUrl: string) {
|
||||||
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return requestJson<T>(backendUrl, path, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site,
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status,
|
||||||
|
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteForm {
|
||||||
|
dstAddress: string
|
||||||
|
routingTable: string
|
||||||
|
comment: string
|
||||||
|
endpoints: RouteEndpointForm[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteEndpointForm {
|
||||||
|
id: string
|
||||||
|
disabled: boolean
|
||||||
|
gateway: string
|
||||||
|
distance: number
|
||||||
|
scope: number | null
|
||||||
|
targetScope: number | null
|
||||||
|
checkGateway: string
|
||||||
|
country: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEndpoint = (): RouteEndpointForm => ({
|
||||||
|
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
disabled: false,
|
||||||
|
gateway: "",
|
||||||
|
distance: 1,
|
||||||
|
scope: null,
|
||||||
|
targetScope: null,
|
||||||
|
checkGateway: "ping",
|
||||||
|
country: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyForm = (): RouteForm => ({
|
||||||
|
dstAddress: "",
|
||||||
|
routingTable: "main",
|
||||||
|
comment: "",
|
||||||
|
endpoints: [newEndpoint()],
|
||||||
|
})
|
||||||
|
|
||||||
|
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const q = query.trim().toUpperCase()
|
||||||
|
const visible = q.length === 0
|
||||||
|
? COUNTRY_OPTIONS
|
||||||
|
: COUNTRY_OPTIONS.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">Страна</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||||
|
setQuery(v)
|
||||||
|
if (v.length === 2) {
|
||||||
|
const match = COUNTRY_OPTIONS.find(c => c.code === v)
|
||||||
|
if (match) onChange(match.code)
|
||||||
|
else onChange(v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="font-mono pr-10 h-8 text-sm" />
|
||||||
|
{value && (
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||||
|
<Flag code={value} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{value && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground shrink-0">
|
||||||
|
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
|
||||||
|
{visible.map(c => (
|
||||||
|
<button key={c.code} type="button"
|
||||||
|
onClick={() => { onChange(c.code); setQuery("") }}
|
||||||
|
title={`${c.code} · ${c.label}`}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
|
||||||
|
value === c.code
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
|
||||||
|
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
<Flag code={c.code} size={24} />
|
||||||
|
<span className="font-mono leading-none">{c.code}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteSheet({
|
||||||
|
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
mode: "create" | "edit"
|
||||||
|
initial: RouteForm
|
||||||
|
onSave: (v: RouteForm) => Promise<boolean>
|
||||||
|
onClose: () => void
|
||||||
|
gateways: GatewayOption[]
|
||||||
|
busy: boolean
|
||||||
|
saveError: string | null
|
||||||
|
returnFocus: () => HTMLElement | null
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState<RouteForm>(initial)
|
||||||
|
const [endpointPage, setEndpointPage] = useState(0)
|
||||||
|
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
|
||||||
|
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
|
||||||
|
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
|
||||||
|
const endpointOffset = currentEndpointPage * 10
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
|
||||||
|
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||||||
|
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||||
|
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||||
|
|
||||||
|
const saveLock = useRef(false)
|
||||||
|
async function handleSave() {
|
||||||
|
if (busy || saveLock.current) return
|
||||||
|
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||||
|
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||||
|
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
|
||||||
|
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
|
||||||
|
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
||||||
|
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
|
||||||
|
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
|
||||||
|
setError(null)
|
||||||
|
saveLock.current = true
|
||||||
|
try { await onSave(form) } finally { saveLock.current = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
||||||
|
<SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||||
|
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||||
|
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>
|
||||||
|
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||||
|
<Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Конечные точки</SectionTitle>
|
||||||
|
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
|
||||||
|
if (event.key !== "Enter") return
|
||||||
|
event.preventDefault()
|
||||||
|
const number = Number(event.currentTarget.value)
|
||||||
|
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
|
||||||
|
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
|
||||||
|
}} />
|
||||||
|
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
|
||||||
|
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
|
||||||
|
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
|
||||||
|
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
|
||||||
|
</div>}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
||||||
|
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||||
|
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
|
||||||
|
disabled={form.endpoints.length <= 1}
|
||||||
|
>
|
||||||
|
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||||
|
|
||||||
|
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||||
|
<Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<FormField label="Distance (приоритет)">
|
||||||
|
<Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Check Gateway">
|
||||||
|
<Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<FormField label="Scope">
|
||||||
|
<Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="T.Scope">
|
||||||
|
<Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
|
||||||
|
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
|
||||||
|
</Button>
|
||||||
|
{gateways.length === 0 && (
|
||||||
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Нет доступных шлюзов на выбранном роутере.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
|
<FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
|
<FormField label="Комментарий">
|
||||||
|
<Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||||||
|
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
|
||||||
|
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
|
||||||
|
</fieldset>
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||||||
|
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
|
||||||
|
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
|
return (
|
||||||
|
<Badge size="xs" className="shrink-0 text-foreground"
|
||||||
|
variant={type === "home-router" ? "success-light" : type === "jump-host" ? "primary-light" : "info-light"}>
|
||||||
|
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecursiveRoutesPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6 text-sm text-muted-foreground">Загрузка маршрутов…</p>}><RecursiveRoutesRoute /></Suspense>
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveRoutesRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams(), context = readRecursiveUrl(new URLSearchParams(params.toString()))
|
||||||
|
const sourceKey = JSON.stringify([mode, backendUrl])
|
||||||
|
const pendingSources = useRef(new Set<string>())
|
||||||
|
const [pending, setPending] = useState<string[]>([])
|
||||||
|
const [readVersions, setReadVersions] = useState<Record<string, number>>({})
|
||||||
|
const beginWrite = useCallback(() => {
|
||||||
|
if (pendingSources.current.has(sourceKey)) return false
|
||||||
|
pendingSources.current.add(sourceKey); setPending([...pendingSources.current]); return true
|
||||||
|
}, [sourceKey])
|
||||||
|
const endWrite = useCallback(() => {
|
||||||
|
pendingSources.current.delete(sourceKey); setPending([...pendingSources.current])
|
||||||
|
setReadVersions(versions => ({ ...versions, [sourceKey]: (versions[sourceKey] ?? 0) + 1 }))
|
||||||
|
}, [sourceKey])
|
||||||
|
const patchUrl = useCallback((patch: RecursiveUrlPatch, replace = false) => {
|
||||||
|
const url = new URL(window.location.href); url.search = patchRecursiveUrl(url.searchParams, patch).toString()
|
||||||
|
const target = url.pathname + url.search + url.hash
|
||||||
|
if (replace) window.history.replaceState(null, "", target); else window.history.pushState(null, "", target)
|
||||||
|
}, [])
|
||||||
|
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl, context.serverId, context.error])} mode={mode} backendUrl={backendUrl} context={context} patchUrl={patchUrl} beginWrite={beginWrite} endWrite={endWrite} sharedBusy={pending.includes(sourceKey)} readVersion={readVersions[sourceKey] ?? 0} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrite, endWrite, sharedBusy, readVersion }: {
|
||||||
|
beginWrite: () => boolean; endWrite: () => void; sharedBusy: boolean; readVersion: number
|
||||||
|
mode: string; backendUrl: string; context: RecursiveUrlState; patchUrl: (patch: RecursiveUrlPatch, replace?: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
|
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
|
||||||
|
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
|
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||||
|
const search = context.q
|
||||||
|
const setSearch = (q: string) => patchUrl({ q }, true)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
|
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||||
|
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||||
|
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||||
|
const expandedGroupKey = context.route && context.table ? JSON.stringify([context.route.toLowerCase(), context.table]) : null
|
||||||
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
|
const [liveStale, setLiveStale] = useState(true)
|
||||||
|
const [routeOwner, setRouteOwner] = useState("")
|
||||||
|
const [readAt, setReadAt] = useState<string | null>(null)
|
||||||
|
const [writeAt, setWriteAt] = useState<string | null>(null)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [gatewayError, setGatewayError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
|
const alive = useRef(false)
|
||||||
|
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true
|
||||||
|
return () => { alive.current = false }
|
||||||
|
}, [])
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
|
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||||
|
const [inventoryTick, setInventoryTick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setLiveServerListReady(true)
|
||||||
|
setServers(mockServers)
|
||||||
|
setSelectedServerId(mockServers[0]?.id ?? "")
|
||||||
|
setRows([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLiveServerListReady(false)
|
||||||
|
let active = true
|
||||||
|
apiFetch<unknown>("/api/servers")
|
||||||
|
.then((data) => {
|
||||||
|
if (!active) return
|
||||||
|
const parsed = recursiveServersSchema.safeParse(data)
|
||||||
|
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
||||||
|
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
|
||||||
|
setServers(mapped)
|
||||||
|
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
|
||||||
|
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!active) return
|
||||||
|
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
|
||||||
|
setServers([])
|
||||||
|
setSelectedServerId("")
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLiveServerListReady(true)
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [isLive, apiFetch, inventoryTick, context.serverId, context.error, patchUrl])
|
||||||
|
|
||||||
|
const loadRoutes = useCallback(async () => {
|
||||||
|
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId) || writeLock.current) return
|
||||||
|
const epoch = ++readEpoch.current
|
||||||
|
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||||
|
try {
|
||||||
|
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||||
|
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
|
||||||
|
const res = parsed.data
|
||||||
|
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
|
||||||
|
if (!alive.current || epoch !== readEpoch.current) return
|
||||||
|
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
|
||||||
|
if (res.live) setReadAt(new Date().toISOString())
|
||||||
|
else setOpError(res.error ?? "Роутер недоступен")
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current || epoch !== readEpoch.current) return
|
||||||
|
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
|
||||||
|
} finally {
|
||||||
|
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
||||||
|
}
|
||||||
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
|
const loadGateways = useCallback(async () => {
|
||||||
|
if (!isLive || !liveServerListReady || !selectedServerId || context.error || !servers.some(s => s.id === selectedServerId)) return
|
||||||
|
const epoch = ++gatewayEpoch.current
|
||||||
|
try {
|
||||||
|
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways/catalog?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||||
|
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
|
||||||
|
const res = parsed.data
|
||||||
|
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
|
||||||
|
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||||
|
setGatewayOptions(res.gateways); setGatewayError(null)
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||||
|
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
||||||
|
}
|
||||||
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch, context.error, servers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void loadRoutes(); void loadGateways()
|
||||||
|
const reads = readEpoch, gateways = gatewayEpoch
|
||||||
|
return () => { reads.current++; gateways.current++ }
|
||||||
|
}, [loadRoutes, loadGateways, readVersion])
|
||||||
|
|
||||||
|
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||||
|
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || sharedBusy || writeLock.current || !can("mm:network:write")) return false
|
||||||
|
if (!beginWrite()) return false
|
||||||
|
writeLock.current = true; readEpoch.current++
|
||||||
|
setOpError(null); setSaveError(null); setBusy("apply")
|
||||||
|
try {
|
||||||
|
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
||||||
|
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||||
|
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
|
||||||
|
if (!alive.current) return false
|
||||||
|
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||||
|
toast.success("Маршруты применены на роутер")
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current) return false
|
||||||
|
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
||||||
|
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
writeLock.current = false
|
||||||
|
endWrite()
|
||||||
|
if (alive.current) setBusy(null)
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, routeOwner, liveStale, busy, sharedBusy, apiFetch, beginWrite, endWrite])
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||||
|
|
||||||
|
const loadRevisions = useCallback(async () => {
|
||||||
|
if (!isLive || !selectedServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<unknown>(
|
||||||
|
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
|
)
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
|
} finally {
|
||||||
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || sharedBusy || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
||||||
|
if (!beginWrite()) throw new Error("Другая операция ещё выполняется")
|
||||||
|
writeLock.current = true; readEpoch.current++
|
||||||
|
setHistoryRestoring(true); setBusy("apply")
|
||||||
|
try {
|
||||||
|
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
|
||||||
|
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||||
|
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
|
||||||
|
if (!alive.current) return
|
||||||
|
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||||
|
toast.success("Версия применена на роутер"); await loadRevisions()
|
||||||
|
} catch (error) {
|
||||||
|
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
writeLock.current = false
|
||||||
|
endWrite()
|
||||||
|
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, servers, busy, sharedBusy, apiFetch, loadRevisions, beginWrite, endWrite])
|
||||||
|
|
||||||
|
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||||
|
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
||||||
|
}
|
||||||
|
const sheetTrigger = useRef<HTMLElement | null>(null)
|
||||||
|
function returnSheetFocus() {
|
||||||
|
const target = sheetTrigger.current
|
||||||
|
if (target?.isConnected) return target
|
||||||
|
const label = target?.getAttribute("aria-label")
|
||||||
|
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
|
||||||
|
}
|
||||||
|
function openCreate() {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
setSaveError(null)
|
||||||
|
setSheetMode("create")
|
||||||
|
setEditingGroupKey(null)
|
||||||
|
setSheetInitial(emptyForm())
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
function openEdit(group: RouteGroup) {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
|
setSaveError(null)
|
||||||
|
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
|
||||||
|
setSheetMode("edit")
|
||||||
|
setEditingGroupKey(group.key)
|
||||||
|
setSheetInitial({
|
||||||
|
dstAddress: group.dstAddress,
|
||||||
|
routingTable: group.routingTable,
|
||||||
|
comment: group.comment,
|
||||||
|
endpoints: endpoints.map(ep => ({
|
||||||
|
id: ep.id,
|
||||||
|
disabled: ep.disabled,
|
||||||
|
gateway: ep.gateway,
|
||||||
|
distance: ep.distance,
|
||||||
|
scope: ep.scope,
|
||||||
|
targetScope: ep.targetScope,
|
||||||
|
checkGateway: ep.checkGateway,
|
||||||
|
country: ep.country || "",
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
async function handleSaveSheet(v: RouteForm) {
|
||||||
|
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||||
|
id,
|
||||||
|
dstAddress: v.dstAddress,
|
||||||
|
gateway: ep.gateway,
|
||||||
|
distance: ep.distance,
|
||||||
|
scope: ep.scope,
|
||||||
|
targetScope: ep.targetScope,
|
||||||
|
routingTable: v.routingTable,
|
||||||
|
checkGateway: ep.checkGateway,
|
||||||
|
comment: v.comment,
|
||||||
|
disabled: ep.disabled,
|
||||||
|
country: ep.country || "",
|
||||||
|
})
|
||||||
|
let next: RecursiveRouteRow[]
|
||||||
|
if (sheetMode === "create") {
|
||||||
|
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
|
next = [...rows, ...expanded]
|
||||||
|
} else if (editingGroupKey) {
|
||||||
|
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||||
|
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
|
next = [...kept, ...expanded]
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const saved = await applyRoutes(next)
|
||||||
|
if (saved && alive.current) setSheetOpen(false)
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextError = context.error ?? (isLive && liveServerListReady && context.serverId && !servers.some(s => s.id === context.serverId) ? "Устройство из ссылки недоступно" : null)
|
||||||
|
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
||||||
|
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||||
|
const mutationsLocked = Boolean(contextError) || !isLive || busy !== null || sharedBusy || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
||||||
|
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
servers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status ?? undefined,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
}))
|
||||||
|
), [servers])
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
if (!q) return rows
|
||||||
|
return rows.filter(r =>
|
||||||
|
r.dstAddress.toLowerCase().includes(q) ||
|
||||||
|
r.gateway.toLowerCase().includes(q) ||
|
||||||
|
r.routingTable.toLowerCase().includes(q) ||
|
||||||
|
r.comment.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [rows, search])
|
||||||
|
const groupedRoutes = useMemo(() => {
|
||||||
|
const map = new Map<string, RouteGroup>()
|
||||||
|
for (const r of filteredRows) {
|
||||||
|
const key = groupKeyOf(r)
|
||||||
|
const ex = map.get(key)
|
||||||
|
if (ex) {
|
||||||
|
ex.endpoints.push(r)
|
||||||
|
if (!ex.comment && r.comment) ex.comment = r.comment
|
||||||
|
}
|
||||||
|
else map.set(key, {
|
||||||
|
key,
|
||||||
|
dstAddress: r.dstAddress,
|
||||||
|
routingTable: r.routingTable,
|
||||||
|
comment: r.comment,
|
||||||
|
endpoints: [r],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...map.values()]
|
||||||
|
}, [filteredRows])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={rrRailItems}
|
||||||
|
selectedId={selectedServerId}
|
||||||
|
onSelect={id => {
|
||||||
|
if (writeLock.current || sharedBusy || id === selectedServerId) return
|
||||||
|
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
||||||
|
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
||||||
|
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
||||||
|
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
|
||||||
|
patchUrl({ serverId: id, route: null, table: null })
|
||||||
|
}}
|
||||||
|
preserveSelection={true}
|
||||||
|
showAll={false}
|
||||||
|
showCount={false}
|
||||||
|
loading={isLive && !liveServerListReady}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
{isLive && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
||||||
|
disabled={busy !== null}
|
||||||
|
title="Прочитать маршруты с роутера"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
disabled={busy !== null}
|
||||||
|
>
|
||||||
|
<HistoryIcon aria-hidden="true" className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||||
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
banner={
|
||||||
|
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||||
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
|
<SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||||
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
{search && (
|
||||||
|
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
|
<XIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||||
|
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
|
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
|
</p>
|
||||||
|
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
|
||||||
|
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
|
||||||
|
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
|
||||||
|
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
|
||||||
|
{(opError || saveError) && (
|
||||||
|
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
||||||
|
{opError || saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
|
||||||
|
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
|
||||||
|
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
|
||||||
|
{isLive && liveStale && (
|
||||||
|
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isLive ? (
|
||||||
|
<Frame dense className="w-full">
|
||||||
|
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||||
|
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
) : (
|
||||||
|
<DataPageCard>
|
||||||
|
{currentServer && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||||
|
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
||||||
|
<Flag code={currentServer.country} size={16} />
|
||||||
|
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||||
|
{currentServer.type && <TypeChip type={currentServer.type} />}
|
||||||
|
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||||
|
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||||||
|
{currentServer.latency !== null && (
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs font-mono",
|
||||||
|
currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
|
||||||
|
)}>{currentServer.latency}мс</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecursiveRoutesDataGrid
|
||||||
|
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||||
|
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||||
|
expandedKey={expandedGroupKey}
|
||||||
|
onExpandedChange={key => {
|
||||||
|
const group = groupedRoutes.find(group => group.key === key)
|
||||||
|
patchUrl({ route: group?.dstAddress ?? null, table: group?.routingTable ?? null })
|
||||||
|
}}
|
||||||
|
mutationsDisabled={mutationsLocked}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={(g) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={openCreate} disabled={mutationsLocked}
|
||||||
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||||||
|
<PlusIcon aria-hidden="true" className="size-3.5" />
|
||||||
|
Добавить маршрут
|
||||||
|
</button>
|
||||||
|
</DataPageCard>
|
||||||
|
)}
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
<RouteSheet
|
||||||
|
returnFocus={returnSheetFocus}
|
||||||
|
busy={busy !== null}
|
||||||
|
saveError={saveError}
|
||||||
|
open={sheetOpen}
|
||||||
|
mode={sheetMode}
|
||||||
|
initial={sheetInitial}
|
||||||
|
onSave={handleSaveSheet}
|
||||||
|
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
||||||
|
gateways={gatewayOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
|
title="История маршрутов"
|
||||||
|
itemLabel="маршрутов"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || (busy !== null || sharedBusy) && !historyRestoring}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
||||||
|
import {
|
||||||
|
formatCommitSubject,
|
||||||
|
getReleaseCommitPreview,
|
||||||
|
} from "@/lib/release-commits"
|
||||||
|
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ExternalLinkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||||
|
if (value) {
|
||||||
|
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(value))
|
||||||
|
}
|
||||||
|
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReleasesPage() {
|
||||||
|
const manifest = readReleaseManifest()
|
||||||
|
const version = getAppVersion()
|
||||||
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
|
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||||
|
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||||
|
actions={
|
||||||
|
releaseUrl ? (
|
||||||
|
<Link
|
||||||
|
href={releaseUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||||
|
Открыть релиз
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
<div className="max-w-[960px] mx-auto space-y-6">
|
||||||
|
<OpsPanel
|
||||||
|
title="Текущая версия"
|
||||||
|
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||||
|
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||||
|
</p>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
|
||||||
|
|
||||||
|
<OpsPanel
|
||||||
|
title="Сводка коммитов"
|
||||||
|
description={
|
||||||
|
commits.length > 0
|
||||||
|
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||||
|
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||||
|
}
|
||||||
|
contentClassName="px-5 pb-5"
|
||||||
|
>
|
||||||
|
{commits.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{commits.map((commit) => (
|
||||||
|
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono uppercase">{commit.type}</span>
|
||||||
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
|
<span>{commit.author}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,983 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
|
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||||
|
import { useServerUrlState } from "@/hooks/use-server-url-state"
|
||||||
|
import { ServerFilters } from "@/components/data-grids/server-filters"
|
||||||
|
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
|
||||||
|
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
|
||||||
|
import { servers as initialServers } from "@/lib/data"
|
||||||
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
|
import {
|
||||||
|
createServer,
|
||||||
|
deleteServer,
|
||||||
|
getServer,
|
||||||
|
listServers,
|
||||||
|
pollServer,
|
||||||
|
testServerConnection,
|
||||||
|
updateServer,
|
||||||
|
} from "@/shared/api/servers"
|
||||||
|
|
||||||
|
// ─── Backend integration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
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 {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui-kit/accessible-stepper"
|
||||||
|
import {
|
||||||
|
RefreshCwIcon, PlusIcon, TrashIcon,
|
||||||
|
EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
// ─── Countries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const COUNTRIES = [
|
||||||
|
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||||
|
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||||
|
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
|
||||||
|
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
|
||||||
|
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
|
||||||
|
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
|
||||||
|
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
|
||||||
|
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
|
||||||
|
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
|
||||||
|
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
|
||||||
|
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
|
||||||
|
{ code: "NO", label: "Норвегия" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Shared small components ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Country field ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const q = query.trim().toUpperCase()
|
||||||
|
const visible = q.length === 0
|
||||||
|
? COUNTRIES
|
||||||
|
: COUNTRIES.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">Страна <span className="text-destructive">*</span></label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||||
|
setQuery(v)
|
||||||
|
if (v.length === 2) {
|
||||||
|
const match = COUNTRIES.find(c => c.code === v)
|
||||||
|
if (match) onChange(match.code)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="font-mono pr-10 h-8 text-sm" />
|
||||||
|
{value && (
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||||
|
<Flag code={value} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{value && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground shrink-0">
|
||||||
|
{COUNTRIES.find(c => c.code === value)?.label ?? value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1.5 max-h-52 overflow-y-auto pr-0.5">
|
||||||
|
{visible.map(c => (
|
||||||
|
<button key={c.code} type="button"
|
||||||
|
onClick={() => { onChange(c.code); setQuery("") }}
|
||||||
|
title={`${c.code} · ${c.label}`}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
|
||||||
|
value === c.code
|
||||||
|
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
|
||||||
|
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
|
||||||
|
)}>
|
||||||
|
<Flag code={c.code} size={24} />
|
||||||
|
<span className="font-mono leading-none">{c.code}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WAN uplinks editor ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function newWan(): WanUplink {
|
||||||
|
return { id: `w${Date.now()}`, name: "", isp: "", iface: "ether1", ip: "", maxDl: 100, maxUl: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function WanUplinkEditor({ wans, onChange }: {
|
||||||
|
wans: WanUplink[]
|
||||||
|
onChange: (wans: WanUplink[]) => void
|
||||||
|
}) {
|
||||||
|
function updateWan(id: string, patch: Partial<WanUplink>) {
|
||||||
|
onChange(wans.map(w => w.id === id ? { ...w, ...patch } : w))
|
||||||
|
}
|
||||||
|
function removeWan(id: string) { onChange(wans.filter(w => w.id !== id)) }
|
||||||
|
function addWan() { onChange([...wans, newWan()]) }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{wans.map((wan, idx) => (
|
||||||
|
<div key={wan.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
WAN {idx + 1}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => removeWan(wan.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors">
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<FormField label="Имя" required>
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
||||||
|
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Интерфейс">
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
||||||
|
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Провайдер (ISP)">
|
||||||
|
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
||||||
|
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Внешний IP">
|
||||||
|
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
||||||
|
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="↓ Макс. Мбит">
|
||||||
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
|
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="↑ Макс. Мбит">
|
||||||
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
|
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={addWan}>
|
||||||
|
<PlusIcon className="size-3.5" />Добавить WAN-аплинк
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Form state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
name: "", type: "exit-node" as ServerType, site: "", country: "RU", asn: "", enabled: true,
|
||||||
|
host: "", port: "443", proto: "https" as "http" | "https",
|
||||||
|
verifySsl: true, timeout: 10,
|
||||||
|
apiPath: "/rest", username: "", password: "", showPassword: false,
|
||||||
|
showAdvanced: false, sshPort: 22, winboxPort: 8291, comment: "",
|
||||||
|
// home-router extras
|
||||||
|
lanSubnet: "",
|
||||||
|
wanUplinks: [] as WanUplink[],
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = typeof defaultForm
|
||||||
|
type TestState = "idle" | "testing" | "ok" | "error"
|
||||||
|
type TypeFilter = "all" | ServerType
|
||||||
|
type SheetMode = "add" | "edit"
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function ServersPageContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const selectedServerId = searchParams.get("server")
|
||||||
|
const clearParams = new URLSearchParams(searchParams.toString())
|
||||||
|
clearParams.delete("server")
|
||||||
|
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
|
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
|
||||||
|
const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [sheetStep, setSheetStep] = useState(1)
|
||||||
|
const [form, setForm] = useState<FormState>(defaultForm)
|
||||||
|
const [testState, setTestState] = useState<TestState>("idle")
|
||||||
|
const [testMsg, setTestMsg] = useState("")
|
||||||
|
const [pollingIds, setPollingIds] = useState<Set<string>>(new Set())
|
||||||
|
const [pollAllBusy, setPollAllBusy] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(false)
|
||||||
|
const [detailsReady, setDetailsReady] = useState(true)
|
||||||
|
const [formError, setFormError] = useState("")
|
||||||
|
const [operationMessage, setOperationMessage] = useState("")
|
||||||
|
const [operationError, setOperationError] = useState("")
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState("")
|
||||||
|
const [pollResults, setPollResults] = useState<PollResult[]>([])
|
||||||
|
const [rowBusy, setRowBusy] = useState(false)
|
||||||
|
const gate = useRef(new OperationGate())
|
||||||
|
const formVersion = useRef(0)
|
||||||
|
const testVersion = useRef(0)
|
||||||
|
const contextVersion = useRef(0)
|
||||||
|
const canWrite = can("mm:servers:write")
|
||||||
|
const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись."
|
||||||
|
function requireWrite(report: (message: string) => void = setOperationError) {
|
||||||
|
if (can("mm:servers:write")) return true
|
||||||
|
report(accessDenied)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
|
||||||
|
|
||||||
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) => {
|
||||||
|
if (gate.current.has("save") || detailsLoading) return
|
||||||
|
if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) {
|
||||||
|
testVersion.current++
|
||||||
|
setTestState("idle"); setTestMsg("")
|
||||||
|
}
|
||||||
|
setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm(next: boolean) {
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
if (!next) { formVersion.current++; testVersion.current++ }
|
||||||
|
setOpen(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── load from backend when mode/url changes ───────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const version = ++contextVersion.current
|
||||||
|
let cancelled = false
|
||||||
|
const invalidate = () => {
|
||||||
|
cancelled = true
|
||||||
|
contextVersion.current++
|
||||||
|
formVersion.current++
|
||||||
|
testVersion.current++
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||||
|
setListState("loading")
|
||||||
|
})
|
||||||
|
if (mode !== "live") {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setServerList(initialServers)
|
||||||
|
setListState("ready")
|
||||||
|
})
|
||||||
|
return invalidate
|
||||||
|
}
|
||||||
|
listServers(backendUrl)
|
||||||
|
.then(data => {
|
||||||
|
if (cancelled || version !== contextVersion.current) return
|
||||||
|
setListState("ready")
|
||||||
|
setServerList(data.map(toFrontendServer))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled || version !== contextVersion.current) return
|
||||||
|
setOperationError(serverOperationError(error))
|
||||||
|
setListState("error")
|
||||||
|
setServerList([])
|
||||||
|
})
|
||||||
|
return invalidate
|
||||||
|
}, [mode, backendUrl])
|
||||||
|
|
||||||
|
// ── actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setFormError(""); setDetailsLoading(false); setDetailsReady(true)
|
||||||
|
setSheetMode("add"); setEditingId(null)
|
||||||
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
|
setSheetStep(1)
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(s: Server) {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (gate.current.has("save")) return
|
||||||
|
const version = ++formVersion.current
|
||||||
|
testVersion.current++
|
||||||
|
setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive)
|
||||||
|
setSheetMode("edit"); setEditingId(s.id)
|
||||||
|
setForm({
|
||||||
|
...defaultForm,
|
||||||
|
name: s.name, type: s.type, site: s.site, country: s.country ?? "RU",
|
||||||
|
asn: s.asn ?? "", enabled: s.enabled, host: s.host,
|
||||||
|
comment: s.comment ?? "",
|
||||||
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||||
|
})
|
||||||
|
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
|
||||||
|
|
||||||
|
// Fetch full server details (including credentials) from backend
|
||||||
|
if (isLive) {
|
||||||
|
try {
|
||||||
|
const full = await getServer(backendUrl, s.id)
|
||||||
|
if (version !== formVersion.current) return
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
username: full.username ?? "",
|
||||||
|
enabled: full.enabled,
|
||||||
|
password: full.password ?? "",
|
||||||
|
port: String(full.port ?? 443),
|
||||||
|
proto: full.useSsl ? "https" : "http",
|
||||||
|
verifySsl: full.verifySsl ?? false,
|
||||||
|
lanSubnet: full.lanSubnet ?? prev.lanSubnet,
|
||||||
|
wanUplinks:
|
||||||
|
Array.isArray(full.wanUplinks) && full.wanUplinks.length
|
||||||
|
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||||
|
: prev.wanUplinks,
|
||||||
|
}))
|
||||||
|
setDetailsReady(true)
|
||||||
|
} catch (error) {
|
||||||
|
if (version === formVersion.current) setFormError(serverOperationError(error))
|
||||||
|
} finally {
|
||||||
|
if (version === formVersion.current) setDetailsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!requireWrite(setFormError)) return
|
||||||
|
if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setSaving(true); setFormError("")
|
||||||
|
try {
|
||||||
|
const payload: ServerCreate = {
|
||||||
|
host: form.host,
|
||||||
|
port: Number(form.port),
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
useSsl: form.proto === "https",
|
||||||
|
verifySsl: form.verifySsl,
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
site: form.site,
|
||||||
|
country: form.country,
|
||||||
|
asn: form.asn,
|
||||||
|
comment: form.comment,
|
||||||
|
enabled: form.enabled,
|
||||||
|
lanSubnet: form.lanSubnet.trim(),
|
||||||
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) {
|
||||||
|
setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.")
|
||||||
|
setSheetStep(3)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const validation = serverCreateSchema.safeParse(payload)
|
||||||
|
if (!validation.success) {
|
||||||
|
setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
try {
|
||||||
|
if (sheetMode === "edit" && editingId) {
|
||||||
|
const updatedPayload: ServerUpdate = payload
|
||||||
|
const updated = await updateServer(backendUrl, editingId, updatedPayload)
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s))
|
||||||
|
} else {
|
||||||
|
const created = await createServer(backendUrl, payload)
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setServerList(list => [...list, toFrontendServer(created)])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context === contextVersion.current) setFormError(serverOperationError(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// offline mode — update local state only
|
||||||
|
if (sheetMode === "edit" && editingId) {
|
||||||
|
setServerList(list => list.map(s =>
|
||||||
|
s.id === editingId
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
site: form.site,
|
||||||
|
country: form.country,
|
||||||
|
host: form.host,
|
||||||
|
asn: form.asn,
|
||||||
|
comment: form.comment,
|
||||||
|
lanSubnet: form.lanSubnet || undefined,
|
||||||
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
setServerList(list => [...list, {
|
||||||
|
id: `srv${Date.now()}`, name: form.name || form.host,
|
||||||
|
host: form.host, type: form.type, site: form.site,
|
||||||
|
country: form.country, asn: form.asn, model: "Unknown", os: "—",
|
||||||
|
status: form.enabled ? "online" : "offline",
|
||||||
|
enabled: form.enabled, latency: null, sessions: 0,
|
||||||
|
comment: form.comment || undefined,
|
||||||
|
lanSubnet: form.lanSubnet || undefined,
|
||||||
|
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false)
|
||||||
|
formVersion.current++; testVersion.current++
|
||||||
|
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
|
||||||
|
} finally {
|
||||||
|
gate.current.release("save"); setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleStatus(id: string) {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
const server = serverList.find(s => s.id === id)
|
||||||
|
if (!server || !gate.current.acquire("rows")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setRowBusy(true); setOperationError(""); setOperationMessage("")
|
||||||
|
try {
|
||||||
|
let enabled = !server.enabled
|
||||||
|
if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s))
|
||||||
|
setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setOperationError(serverOperationError(error))
|
||||||
|
} finally { gate.current.release("rows"); setRowBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!requireWrite(setDeleteError)) return
|
||||||
|
if (!gate.current.acquire("rows")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setDeleting(true); setRowBusy(true); setDeleteError("")
|
||||||
|
try {
|
||||||
|
if (isLive) await deleteServer(backendUrl, id)
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setServerList(list => list.filter(s => s.id !== id))
|
||||||
|
setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.")
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setDeleteError(serverOperationError(error))
|
||||||
|
} finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePoll(id: string) {
|
||||||
|
await runPoll([id])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePollAll() {
|
||||||
|
await runPoll(serverList.map(s => s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPoll(ids: string[]) {
|
||||||
|
if (!requireWrite()) return
|
||||||
|
if (!isLive || !gate.current.acquire("rows")) return
|
||||||
|
const context = contextVersion.current
|
||||||
|
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||||
|
try {
|
||||||
|
const results = await pollServers(ids, async id => {
|
||||||
|
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
||||||
|
if (!can("mm:servers:write")) throw new Error(accessDenied)
|
||||||
|
await pollServer(backendUrl, id)
|
||||||
|
})
|
||||||
|
if (context !== contextVersion.current) return
|
||||||
|
setPollResults(results)
|
||||||
|
try {
|
||||||
|
const data = await listServers(backendUrl)
|
||||||
|
if (context === contextVersion.current) setServerList(data.map(toFrontendServer))
|
||||||
|
} catch (error) {
|
||||||
|
if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (!requireWrite(setFormError)) return
|
||||||
|
if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return
|
||||||
|
const version = ++testVersion.current
|
||||||
|
if (!form.host || !form.username) {
|
||||||
|
setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return
|
||||||
|
}
|
||||||
|
gate.current.acquire("test")
|
||||||
|
setTestState("testing"); setTestMsg("")
|
||||||
|
try {
|
||||||
|
const data = await testServerConnection(backendUrl, {
|
||||||
|
host: form.host,
|
||||||
|
port: Number(form.port) || (form.proto === "https" ? 443 : 80),
|
||||||
|
useSsl: form.proto === "https",
|
||||||
|
verifySsl: form.verifySsl,
|
||||||
|
apiPath: form.apiPath || "/rest",
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
})
|
||||||
|
if (version !== testVersion.current) return
|
||||||
|
if (data.success) {
|
||||||
|
setTestState("ok"); setTestMsg(data.message)
|
||||||
|
} else {
|
||||||
|
setTestState("error"); setTestMsg(data.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (version !== testVersion.current) return
|
||||||
|
setTestState("error")
|
||||||
|
setTestMsg(serverOperationError(error))
|
||||||
|
} finally {
|
||||||
|
gate.current.release("test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedServer = serverList.find(server => server.id === selectedServerId)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (urlError) return []
|
||||||
|
const base = serverList.filter(sv => {
|
||||||
|
if (selectedServerId !== null && sv.id !== selectedServerId) return false
|
||||||
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
|
}, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
|
||||||
|
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
all: serverList.length,
|
||||||
|
"jump-host": serverList.filter(s => s.type === "jump-host").length,
|
||||||
|
"exit-node": serverList.filter(s => s.type === "exit-node").length,
|
||||||
|
"home-router":serverList.filter(s => s.type === "home-router").length,
|
||||||
|
online: serverList.filter(s => s.status === "online").length,
|
||||||
|
}), [serverList])
|
||||||
|
|
||||||
|
const tabs: { value: TypeFilter; label: string }[] = [
|
||||||
|
{ value: "all", label: "Все" },
|
||||||
|
{ value: "jump-host", label: "JumpHost" },
|
||||||
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
|
{ value: "home-router", label: "Home Router" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isHomeRouter = form.type === "home-router"
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
||||||
|
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
|
||||||
|
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
|
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
|
||||||
|
{pollResults.length > 0 && (
|
||||||
|
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
|
||||||
|
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
|
||||||
|
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedServerId !== null && (
|
||||||
|
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
|
||||||
|
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка серверов"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего серверов",
|
||||||
|
value: counts.all,
|
||||||
|
icon: <ServerIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "online",
|
||||||
|
label: "Онлайн",
|
||||||
|
value: counts.online,
|
||||||
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jh-en",
|
||||||
|
label: "JH + Exit Node",
|
||||||
|
value: counts["jump-host"] + counts["exit-node"],
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
label: "Home Router",
|
||||||
|
value: counts["home-router"],
|
||||||
|
icon: <HomeIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
|
||||||
|
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: tabs.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
|
countLabel={`${filtered.length} серверов`}
|
||||||
|
/>
|
||||||
|
<ServersDataGrid
|
||||||
|
servers={filtered}
|
||||||
|
hasUnfilteredRows={serverList.length > 0}
|
||||||
|
isLive={isLive}
|
||||||
|
pollingIds={pollingIds}
|
||||||
|
busy={controlsBusy}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onPoll={handlePoll}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
||||||
|
<Sheet open={open} onOpenChange={closeForm}>
|
||||||
|
<SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
|
||||||
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
|
||||||
|
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
|
||||||
|
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
|
||||||
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
<FormField label="Имя сервера" required hint="Например home-msk-01">
|
||||||
|
<Input className="font-mono" placeholder="home-msk-01"
|
||||||
|
value={form.name} onChange={e => set("name", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Тип узла" required>
|
||||||
|
<SegmentedControl
|
||||||
|
className="max-w-full flex-wrap"
|
||||||
|
value={form.type}
|
||||||
|
onChange={v => set("type", v)}
|
||||||
|
options={[
|
||||||
|
{ value: "home-router", label: "Home Router" },
|
||||||
|
{ value: "jump-host", label: "JumpHost" },
|
||||||
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
|
||||||
|
<Input className="font-mono uppercase" placeholder="MSK"
|
||||||
|
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
||||||
|
</FormField>
|
||||||
|
{!isHomeRouter && (
|
||||||
|
<FormField label="ASN" hint="Например AS65001">
|
||||||
|
<Input className="font-mono" placeholder="AS65001"
|
||||||
|
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{isHomeRouter && (
|
||||||
|
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
||||||
|
<Input className="font-mono" placeholder="192.168.10.0/24"
|
||||||
|
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CountryField value={form.country} onChange={v => set("country", v)} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Включён</span>
|
||||||
|
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
|
<WanUplinkEditor
|
||||||
|
wans={form.wanUplinks}
|
||||||
|
onChange={wans => set("wanUplinks", wans)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||||
|
|
||||||
|
<FormField label="Хост / IP-адрес" required
|
||||||
|
hint={isHomeRouter
|
||||||
|
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
||||||
|
: "Внешний или управляющий IP-адрес роутера"}>
|
||||||
|
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
||||||
|
value={form.host} onChange={e => set("host", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Протокол">
|
||||||
|
<SegmentedControl
|
||||||
|
value={form.proto}
|
||||||
|
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
||||||
|
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Порт" hint="443 / 80">
|
||||||
|
<Input className="font-mono" placeholder="443"
|
||||||
|
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
<ShieldIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
Проверять SSL-сертификат
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||||
|
</div>
|
||||||
|
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Путь API">
|
||||||
|
<Input className="font-mono" placeholder="/rest"
|
||||||
|
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
||||||
|
<p className="font-mono text-foreground">
|
||||||
|
{form.proto}://{form.host || "host"}:{form.port}{form.apiPath}
|
||||||
|
</p>
|
||||||
|
{isHomeRouter && (
|
||||||
|
<p className="mt-1 text-amber-600 dark:text-amber-400">
|
||||||
|
⚠ Home Router доступен только из LAN / через VPN-туннель
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||||
|
<Input className="font-mono" placeholder="api-user"
|
||||||
|
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Пароль" required>
|
||||||
|
<div className="relative">
|
||||||
|
<Input type={form.showPassword ? "text" : "password"}
|
||||||
|
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
||||||
|
value={form.password} onChange={e => set("password", e.target.value)} />
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => set("showPassword", !form.showPassword)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
|
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||||
|
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
|
||||||
|
{testState === "testing"
|
||||||
|
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
|
||||||
|
: <><WifiIcon className="size-4" />Проверить подключение</>}
|
||||||
|
</Button>
|
||||||
|
{testState === "ok" && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||||
|
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||||
|
<CheckCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{testState === "error" && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
|
||||||
|
style={{ background: "var(--status-offline-bg)", color: "var(--status-offline-fg)" }}>
|
||||||
|
<XCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||||
|
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
|
Дополнительно
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</button>
|
||||||
|
{form.showAdvanced && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="SSH-порт">
|
||||||
|
<Input type="number" className="font-mono" value={form.sshPort}
|
||||||
|
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Winbox-порт">
|
||||||
|
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||||
|
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Таймаут соединения, с">
|
||||||
|
<Input type="number" className="font-mono" value={form.timeout}
|
||||||
|
onChange={e => set("timeout", Number(e.target.value))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Комментарий">
|
||||||
|
<Input placeholder="Описание или заметка" value={form.comment}
|
||||||
|
onChange={e => set("comment", e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||||
|
{sheetStep > 1 && (
|
||||||
|
<Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
|
||||||
|
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||||
|
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { useTheme } from "@/components/theme-provider"
|
||||||
|
import { SystemDatabaseSection } from "@/components/settings/system-database-section"
|
||||||
|
import { EvoBgpSettingsSection } from "@/components/settings/evobgp-settings-section"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
AlertCircleIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "API-ключи", "Безопасность"] as const
|
||||||
|
type NavSection = typeof SECTIONS_NAV[number]
|
||||||
|
|
||||||
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start sm:flex-row sm:items-center sm:justify-between gap-3 py-3.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 max-w-full">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [section, setSection] = useState<NavSection>("Общие")
|
||||||
|
const { mode, setMode, backendUrl, backendUrlLocked, mockModeAvailable, backendStatus, backendChecking, checkBackend } = useDataSource()
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const renderContent = () => {
|
||||||
|
|
||||||
|
// ── Общие ──
|
||||||
|
if (section === "Общие") return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* ── Источник данных ── */}
|
||||||
|
<OpsPanel
|
||||||
|
title="Источник данных"
|
||||||
|
description={
|
||||||
|
mockModeAvailable
|
||||||
|
? "Выберите живые или демонстрационные данные. Режим применяется сразу в этом браузере."
|
||||||
|
: "Источник живых данных задан конфигурацией приложения."
|
||||||
|
}
|
||||||
|
contentClassName="divide-y px-5"
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* mode toggle */}
|
||||||
|
{mockModeAvailable ? (
|
||||||
|
<SettingRow label="Режим данных" description="Демонстрационный режим использует тестовые данные. Живой режим обращается к серверу.">
|
||||||
|
<div className="flex rounded-md border border-input overflow-hidden h-8">
|
||||||
|
{([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => (
|
||||||
|
<button key={v} type="button" aria-pressed={mode===v} onClick={() => setMode(v)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 text-xs transition-colors border-input",
|
||||||
|
i === 0 && "border-r",
|
||||||
|
mode === v
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted",
|
||||||
|
)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
) : (
|
||||||
|
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
|
||||||
|
<span className="inline-flex h-8 items-center rounded-md border border-input bg-primary px-3 text-xs text-primary-foreground">
|
||||||
|
Живые
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* backend URL — visible in both modes so user can configure before switching */}
|
||||||
|
<SettingRow
|
||||||
|
label="URL бекенда"
|
||||||
|
description={
|
||||||
|
backendUrlLocked
|
||||||
|
? "Адрес подключения задан конфигурацией приложения."
|
||||||
|
: "Адрес сервера приложения. Изменение применяется после Enter или выхода из поля."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BackendUrlField key={backendUrl} />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* connection status */}
|
||||||
|
<SettingRow label="Статус бекенда">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{backendStatus === undefined && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="size-2 rounded-full bg-muted-foreground/40 inline-block" />
|
||||||
|
{backendChecking ? "Проверка…" : "Не проверялось"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{backendStatus === true && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400">
|
||||||
|
<CheckIcon className="size-3.5" />Доступен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{backendStatus === false && (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||||||
|
<XIcon className="size-3.5" />Недоступен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground"
|
||||||
|
onClick={checkBackend}>
|
||||||
|
Проверить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* hint when live but backend unreachable */}
|
||||||
|
{mode === "live" && backendStatus === false && (
|
||||||
|
<div className="flex items-start gap-2 py-3 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertCircleIcon className="size-3.5 mt-0.5 shrink-0" />
|
||||||
|
<span>Сервер недоступен. Последние полученные данные сохраняются; новые запросы могут завершаться ошибкой.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<OpsPanel title="Интерфейс" description="Тема применяется сразу в этом браузере." contentClassName="divide-y px-5">
|
||||||
|
<SettingRow label="Язык интерфейса"><span className="text-sm">Русский</span></SettingRow>
|
||||||
|
<SettingRow label="Тема оформления"><div className="flex flex-wrap gap-2">
|
||||||
|
{([["light","Светлая"],["dark","Тёмная"],["system","Авто"]] as const).map(([value,label]) => <Button key={value} size="sm" variant={theme===value?"default":"outline"} aria-pressed={theme===value} disabled={!theme} onClick={()=>setTheme(value)}>{label}</Button>)}
|
||||||
|
</div></SettingRow>
|
||||||
|
<p className="py-3 text-xs text-muted-foreground">Периоды сбора данных и параметры диагностики настраиваются в соответствующих разделах.</p>
|
||||||
|
<div className="flex flex-wrap gap-2 pb-4"><Link href="/data-collection" className={buttonVariants({variant:"outline",size:"sm"})}>Сбор данных</Link><Link href="/probes" className={buttonVariants({variant:"outline",size:"sm"})}>Диагностика</Link></div>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
<SystemDatabaseSection />
|
||||||
|
|
||||||
|
<OpsPanel id="route-ai" title="Оптимизатор маршрутов" description="Текущие пороги, веса и режим применения доступны на странице оптимизатора." contentClassName="px-5 py-4"><Link href="/route-optimizer" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оптимизатор</Link></OpsPanel>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (section === "EvoBGP") return <EvoBgpSettingsSection />
|
||||||
|
|
||||||
|
if (section === "Уведомления") return <OpsPanel title="Оповещения" description="Каналы и правила оповещений настраиваются в отдельном разделе." contentClassName="px-5 py-4"><Link href="/alerts" className={buttonVariants({variant:"outline",size:"sm"})}>Открыть оповещения</Link></OpsPanel>
|
||||||
|
if (section === "API-ключи") return <OpsPanel title="API-ключи" contentClassName="px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Создание и управление отдельными API-ключами в приложении пока недоступны.</p></OpsPanel>
|
||||||
|
return <OpsPanel title="Безопасность" description="Параметры доступа задаются в сервисе авторизации и конфигурации сервера." contentClassName="flex flex-col gap-3 px-5 py-4"><p role="status" className="text-sm text-muted-foreground">Состояние MFA, ограничений IP и настроек аудита в этом разделе не получено. Изменение этих параметров здесь недоступно.</p><Link href="/users" className={buttonVariants({variant:"outline",size:"sm"})}>Управление пользователями</Link></OpsPanel>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Система" }, { label: "Настройки" }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="max-w-[960px] mx-auto grid grid-cols-1 md:grid-cols-[180px_minmax(0,1fr)] gap-6">
|
||||||
|
|
||||||
|
{/* nav */}
|
||||||
|
<nav className="flex flex-wrap md:flex-col gap-1 self-start md:sticky md:top-0">
|
||||||
|
{SECTIONS_NAV.map(s => (
|
||||||
|
<button key={s} aria-current={s===section?"page":undefined} onClick={() => setSection(s)}
|
||||||
|
className={cn(
|
||||||
|
"text-left px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
|
s === section ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground hover:text-foreground",
|
||||||
|
)}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div className="min-w-0">{renderContent()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function BackendUrlField(){
|
||||||
|
const {backendUrl,setBackendUrl,backendUrlLocked}=useDataSource()
|
||||||
|
const [draft,setDraft]=useState(backendUrl),[error,setError]=useState<string|null>(null)
|
||||||
|
function commit(){
|
||||||
|
if(backendUrlLocked||draft===backendUrl)return
|
||||||
|
try{const url=new URL(draft.trim());if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash)throw Error();setBackendUrl(url.toString().replace(/\/$/,""));setError(null)}
|
||||||
|
catch{setError("Укажите HTTP(S) адрес без учётных данных и параметров")}
|
||||||
|
}
|
||||||
|
return <div className="flex min-w-0 flex-col gap-2"><Input aria-label="URL бекенда" className="w-full sm:w-64 font-mono" value={draft} onChange={e=>{setDraft(e.target.value);setError(null)}} onBlur={commit} onKeyDown={e=>{if(e.key==="Enter"){e.preventDefault();commit()}}} readOnly={backendUrlLocked} aria-invalid={!!error} placeholder="http://localhost:8000"/>{error?<p role="alert" className="text-xs text-destructive">{error}</p>:null}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
ServerIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||||
|
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
||||||
|
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
|
||||||
|
import { SliceChips } from "@/components/statistics/slice-chips"
|
||||||
|
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
|
||||||
|
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
|
||||||
|
import {
|
||||||
|
StatisticsBreakdownDataGrid,
|
||||||
|
type StatisticsSliceKind,
|
||||||
|
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
||||||
|
import {
|
||||||
|
isStatisticsPivotDim,
|
||||||
|
isStatisticsSliceKind,
|
||||||
|
STATISTICS_DIMS,
|
||||||
|
} from "@/lib/statistics-dims"
|
||||||
|
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
|
||||||
|
import { readStatisticsRange } from "@/lib/statistics-range"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||||
|
import {
|
||||||
|
getStatistics,
|
||||||
|
getStatisticsPivot,
|
||||||
|
STATISTICS_UNBOUND_USER_ID,
|
||||||
|
type StatisticsDto,
|
||||||
|
type StatisticsPivotDto,
|
||||||
|
type StatisticsQuery,
|
||||||
|
} from "@/shared/api/statistics"
|
||||||
|
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BI-куб трафика: критерий → остальные разрезы + pivot.
|
||||||
|
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||||
|
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
|
||||||
|
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMPTY: StatisticsDto = {
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
grain: "day",
|
||||||
|
kpis: {
|
||||||
|
bytes: 0,
|
||||||
|
packets: 0,
|
||||||
|
avgBps: 0,
|
||||||
|
users: 0,
|
||||||
|
servers: 0,
|
||||||
|
ifaces: 0,
|
||||||
|
topCountry: "",
|
||||||
|
topService: "",
|
||||||
|
},
|
||||||
|
series: [],
|
||||||
|
users: [],
|
||||||
|
servers: [],
|
||||||
|
interfaces: [],
|
||||||
|
countries: [],
|
||||||
|
services: [],
|
||||||
|
asns: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PIVOT: StatisticsPivotDto = {
|
||||||
|
rowDim: "country",
|
||||||
|
colDim: "service",
|
||||||
|
metric: "bytes",
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
otherBytes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CubeSlices {
|
||||||
|
country?: string
|
||||||
|
service?: string
|
||||||
|
asn?: string
|
||||||
|
serverId?: string
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||||
|
|
||||||
|
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||||
|
const t = sp.get("dim") ?? sp.get("tab")
|
||||||
|
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readView(sp: URLSearchParams): "explore" | "pivot" {
|
||||||
|
return sp.get("view") === "pivot" ? "pivot" : "explore"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlanes(sp: URLSearchParams): "unique" | "all" {
|
||||||
|
return sp.get("planes") === "all" ? "all" : "unique"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
|
||||||
|
const v = sp.get(key)
|
||||||
|
return v && isStatisticsPivotDim(v) ? v : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSlices(sp: URLSearchParams): CubeSlices {
|
||||||
|
const next: CubeSlices = {}
|
||||||
|
for (const key of SLICE_KEYS) {
|
||||||
|
const v = sp.get(key)?.trim()
|
||||||
|
if (v) next[key] = v
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||||
|
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||||
|
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||||
|
return {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
|
||||||
|
userId: slices.userId,
|
||||||
|
iface: slices.iface,
|
||||||
|
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
||||||
|
service: slices.service,
|
||||||
|
asn: Number.isFinite(asn) ? asn : undefined,
|
||||||
|
planes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
||||||
|
if (kind === "users") return slices.userId
|
||||||
|
if (kind === "servers") return slices.serverId
|
||||||
|
if (kind === "countries") return slices.country
|
||||||
|
if (kind === "services") return slices.service
|
||||||
|
if (kind === "asns") return slices.asn
|
||||||
|
if (kind === "interfaces" && slices.serverId && slices.iface) {
|
||||||
|
return `${slices.serverId}:${slices.iface}`
|
||||||
|
}
|
||||||
|
if (kind === "interfaces") return slices.iface
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
|
||||||
|
if (kind === "users") return data.users
|
||||||
|
if (kind === "servers") return data.servers
|
||||||
|
if (kind === "interfaces") return data.interfaces
|
||||||
|
if (kind === "countries") return data.countries
|
||||||
|
if (kind === "services") return data.services
|
||||||
|
return data.asns
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySlice(slices: CubeSlices): boolean {
|
||||||
|
return SLICE_KEYS.some((k) => Boolean(slices[k]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
|
||||||
|
const hidden = new Set<StatisticsSliceKind>()
|
||||||
|
if (slices.userId) hidden.add("users")
|
||||||
|
if (slices.serverId) hidden.add("servers")
|
||||||
|
if (slices.iface) hidden.add("interfaces")
|
||||||
|
if (slices.country) hidden.add("countries")
|
||||||
|
if (slices.service) hidden.add("services")
|
||||||
|
if (slices.asn) hidden.add("asns")
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
|
||||||
|
const next: CubeSlices = { ...slices }
|
||||||
|
if (kind === "users") {
|
||||||
|
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
|
||||||
|
if (next.userId === rowId) delete next.userId
|
||||||
|
else next.userId = rowId
|
||||||
|
} else if (kind === "servers") {
|
||||||
|
if (next.serverId === rowId) delete next.serverId
|
||||||
|
else next.serverId = rowId
|
||||||
|
} else if (kind === "countries") {
|
||||||
|
if (next.country === rowId) delete next.country
|
||||||
|
else next.country = rowId
|
||||||
|
} else if (kind === "services") {
|
||||||
|
if (next.service === rowId) delete next.service
|
||||||
|
else next.service = rowId
|
||||||
|
} else if (kind === "asns") {
|
||||||
|
if (next.asn === rowId) delete next.asn
|
||||||
|
else next.asn = rowId
|
||||||
|
} else {
|
||||||
|
const colon = rowId.indexOf(":")
|
||||||
|
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
|
||||||
|
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
|
||||||
|
if (next.iface === iface && next.serverId === sid) {
|
||||||
|
delete next.iface
|
||||||
|
delete next.serverId
|
||||||
|
} else {
|
||||||
|
next.iface = iface
|
||||||
|
if (sid) next.serverId = sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
|
||||||
|
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
|
||||||
|
return applyDimValue(slices, kind, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipList(slices: CubeSlices): { key: string; label: string }[] {
|
||||||
|
const chips: { key: string; label: string }[] = []
|
||||||
|
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
|
||||||
|
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
|
||||||
|
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
|
||||||
|
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
|
||||||
|
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
|
||||||
|
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatisticsPageInner() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
|
||||||
|
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
|
||||||
|
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||||
|
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||||
|
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||||
|
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||||
|
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||||
|
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||||
|
|
||||||
|
const [retry, setRetry] = useState(0)
|
||||||
|
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
|
||||||
|
} | null>(null)
|
||||||
|
const current = result?.key === requestKey ? result : null
|
||||||
|
const loading = isLive && !rangeError && !paramsError && current === null
|
||||||
|
const error = rangeError ?? paramsError ?? current?.error ?? null
|
||||||
|
const dataReady = isLive && current !== null && !error
|
||||||
|
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
|
||||||
|
|
||||||
|
const updateParams = useCallback(
|
||||||
|
(patch: Record<string, string | undefined>) => {
|
||||||
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v) sp.set(k, v)
|
||||||
|
else sp.delete(k)
|
||||||
|
}
|
||||||
|
const qs = sp.toString()
|
||||||
|
if (qs === searchParams.toString()) return
|
||||||
|
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
|
||||||
|
},
|
||||||
|
[searchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setRange = useCallback(
|
||||||
|
(next: DateRangeYmd) => {
|
||||||
|
updateParams({ from: next.from, to: next.to })
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setSlices = useCallback(
|
||||||
|
(next: CubeSlices) => {
|
||||||
|
updateParams({
|
||||||
|
country: next.country,
|
||||||
|
service: next.service,
|
||||||
|
asn: next.asn,
|
||||||
|
serverId: next.serverId,
|
||||||
|
userId: next.userId,
|
||||||
|
iface: next.iface,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[updateParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefsHydrated || !isLive || rangeError || paramsError) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const query = toQuery(range, slices, planes)
|
||||||
|
const dto = await getStatistics(backendUrl, query)
|
||||||
|
if (cancelled) return
|
||||||
|
let matrix = EMPTY_PIVOT
|
||||||
|
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||||
|
matrix = await getStatisticsPivot(backendUrl, {
|
||||||
|
...query,
|
||||||
|
row: pivotRow,
|
||||||
|
col: pivotCol,
|
||||||
|
metric: "bytes",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
|
||||||
|
|
||||||
|
const viewData = dataReady ? current.data : EMPTY
|
||||||
|
const sliced = hasAnySlice(slices)
|
||||||
|
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||||
|
|
||||||
|
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
|
||||||
|
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
|
||||||
|
if (kind === "interfaces" && row.label.includes("· дубль")) return
|
||||||
|
setSlices(applyDimValue(slices, kind, row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePivotCell(rowId: string, colId: string) {
|
||||||
|
if (rowId === "__other__" || colId === "__other__") return
|
||||||
|
let next = applyPivotDim(slices, pivotRow, rowId)
|
||||||
|
next = applyPivotDim(next, pivotCol, colId)
|
||||||
|
updateParams({
|
||||||
|
country: next.country,
|
||||||
|
service: next.service,
|
||||||
|
asn: next.asn,
|
||||||
|
serverId: next.serverId,
|
||||||
|
userId: next.userId,
|
||||||
|
iface: next.iface,
|
||||||
|
view: "explore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = viewData.kpis
|
||||||
|
const chips = chipList(slices)
|
||||||
|
const countLabel =
|
||||||
|
view === "pivot"
|
||||||
|
? `${pivot.rows.length} × ${pivot.columns.length}`
|
||||||
|
: sliced
|
||||||
|
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
|
||||||
|
: `${rowsForKind(viewData, dim).length} строк`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||||
|
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
{!isLive ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Живые данные выключены</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive" role="alert">
|
||||||
|
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
||||||
|
<AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!slices.serverId && dataReady && !emptyCube ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Уникальный объём</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(dataReady || loading) && <KpiStatGrid
|
||||||
|
aria-label="Сводка трафика"
|
||||||
|
isLoading={loading}
|
||||||
|
skeletonCount={5}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
label: "Объём",
|
||||||
|
value: formatBytes(kpis.bytes),
|
||||||
|
hint: "GRE/WG клиентов, без hops",
|
||||||
|
icon: <DatabaseIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "packets",
|
||||||
|
label: "Пакеты",
|
||||||
|
value: kpis.packets.toLocaleString("ru-RU"),
|
||||||
|
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bps",
|
||||||
|
label: "Средний bitrate",
|
||||||
|
value: fmtBps(kpis.avgBps),
|
||||||
|
icon: <GaugeIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
label: "Пользователи",
|
||||||
|
value: String(kpis.users),
|
||||||
|
icon: <UsersIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверы",
|
||||||
|
value: String(kpis.servers),
|
||||||
|
hint: slices.serverId
|
||||||
|
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
|
||||||
|
: planes === "all"
|
||||||
|
? "WAN и дубли в списке"
|
||||||
|
: "без WAN и overlay",
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
||||||
|
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
leading={
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
||||||
|
options={[
|
||||||
|
{ value: "explore", label: "Разрез" },
|
||||||
|
{ value: "pivot", label: "Сводка" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={planes}
|
||||||
|
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
|
||||||
|
options={[
|
||||||
|
{ value: "unique", label: "Уникальный" },
|
||||||
|
{ value: "all", label: "Все плоскости" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{view === "explore" && !sliced ? (
|
||||||
|
<DimensionSelect
|
||||||
|
label="Критерий"
|
||||||
|
value={dim}
|
||||||
|
onChange={(next) => updateParams({ dim: next, tab: undefined })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{view === "pivot" ? (
|
||||||
|
<>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Строки"
|
||||||
|
value={pivotRow}
|
||||||
|
exclude={pivotCol}
|
||||||
|
onChange={(next) => updateParams({ pivotRow: next })}
|
||||||
|
/>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Колонки"
|
||||||
|
value={pivotCol}
|
||||||
|
exclude={pivotRow}
|
||||||
|
onChange={(next) => updateParams({ pivotCol: next })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
||||||
|
countLabel={dataReady ? countLabel : undefined}
|
||||||
|
/>
|
||||||
|
<SliceChips
|
||||||
|
chips={chips}
|
||||||
|
onRemove={(key) => {
|
||||||
|
const next = { ...slices }
|
||||||
|
delete next[key as keyof CubeSlices]
|
||||||
|
setSlices(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isLive && !dataReady ? (
|
||||||
|
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
||||||
|
) : emptyCube ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет данных куба"
|
||||||
|
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||||
|
/>
|
||||||
|
) : view === "pivot" ? (
|
||||||
|
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
|
||||||
|
) : sliced ? (
|
||||||
|
<BreakdownDashboard
|
||||||
|
data={viewData}
|
||||||
|
hidden={hiddenKinds(slices)}
|
||||||
|
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatisticsBreakdownDataGrid
|
||||||
|
rows={rowsForKind(viewData, dim)}
|
||||||
|
kind={dim}
|
||||||
|
selectedId={selectedIdForKind(dim, slices)}
|
||||||
|
onRowClick={(row) => handleRowClick(dim, row)}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatisticsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<StatisticsPageInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, Suspense } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { servers as mockServers } from "@/lib/data"
|
||||||
|
import {useSearchParams} from "next/navigation"
|
||||||
|
import {can} from "@/lib/auth"
|
||||||
|
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
|
||||||
|
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
|
||||||
|
import {
|
||||||
|
TrashIcon, RefreshCwIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Unified server representation for the terminal sidebar */
|
||||||
|
interface TermServer {
|
||||||
|
uid: string // unique key (mock id or String(backend id))
|
||||||
|
backendId: number | null // null in mock mode
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
country: string
|
||||||
|
status: "online" | "offline" | "degraded" | null
|
||||||
|
enabled: boolean
|
||||||
|
rosVersion: string | null
|
||||||
|
identityName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TermLine {
|
||||||
|
id: number
|
||||||
|
kind: "output" | "prompt" | "error" | "info"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── mock CLI responses ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CmdFn = (args: string[], serverName: string) => string
|
||||||
|
|
||||||
|
const MOCK_COMMANDS: Record<string, CmdFn> = {
|
||||||
|
"ip address print": () =>
|
||||||
|
"Flags: X - disabled, I - invalid, D - dynamic\n" +
|
||||||
|
" # ADDRESS NETWORK INTERFACE\n" +
|
||||||
|
" 0 10.0.0.1/24 10.0.0.0 ether1-wan\n" +
|
||||||
|
" 1 192.168.88.1/24 192.168.88.0 bridge-lan\n" +
|
||||||
|
" 2 10.200.0.1/30 10.200.0.0 gre-msk-spb\n" +
|
||||||
|
" 3 10.200.0.5/30 10.200.0.4 gre-msk-fra\n" +
|
||||||
|
" 4 D 169.254.0.0/16 169.254.0.0 lo0",
|
||||||
|
|
||||||
|
"ip route print": () =>
|
||||||
|
"Flags: A - active, D - dynamic, C - connect, S - static, b - bgp, o - ospf\n" +
|
||||||
|
" # DST-ADDRESS PREF-SRC GATEWAY DIST\n" +
|
||||||
|
" 0 ADS 0.0.0.0/0 10.0.0.254 1\n" +
|
||||||
|
" 1 Ab 1.1.1.0/24 10.0.1.1 20\n" +
|
||||||
|
" 2 Ab 8.8.8.0/24 10.0.1.1 20\n" +
|
||||||
|
" 3 ADC 10.0.0.0/24 10.0.0.1 0\n" +
|
||||||
|
" 4 ADC 10.200.0.0/30 10.200.0.1 0",
|
||||||
|
|
||||||
|
"interface print": () =>
|
||||||
|
"Flags: X - disabled, D - dynamic, R - running\n" +
|
||||||
|
" # NAME TYPE MTU\n" +
|
||||||
|
" 0 R ether1-wan ether 1500\n" +
|
||||||
|
" 1 R ether2-lan ether 1500\n" +
|
||||||
|
" 2 R bridge-lan bridge 1500\n" +
|
||||||
|
" 3 R gre-msk-spb gre 1476\n" +
|
||||||
|
" 4 R gre-msk-fra gre 1476\n" +
|
||||||
|
" 5 gre-msk-ams gre 1476",
|
||||||
|
|
||||||
|
"system resource print": () =>
|
||||||
|
" uptime: 12d 4h 22m 18s\n" +
|
||||||
|
" version: 7.14.3 (stable)\n" +
|
||||||
|
" free-memory: 512.0 MiB\n" +
|
||||||
|
" total-memory: 2048.0 MiB\n" +
|
||||||
|
" cpu: Intel(R) Xeon(R)\n" +
|
||||||
|
" cpu-count: 4\n" +
|
||||||
|
" cpu-frequency: 3200 MHz\n" +
|
||||||
|
" cpu-load: 12%\n" +
|
||||||
|
" free-hdd-space: 8192.0 MiB\n" +
|
||||||
|
" total-hdd-space: 16384.0 MiB\n" +
|
||||||
|
" architecture-name: x86\n" +
|
||||||
|
" board-name: CHR\n" +
|
||||||
|
" platform: MikroTik",
|
||||||
|
|
||||||
|
"system identity print": (_, n) => ` name: ${n}`,
|
||||||
|
|
||||||
|
"ip firewall filter print": () =>
|
||||||
|
"Flags: X - disabled, I - invalid, D - dynamic\n\n" +
|
||||||
|
" 0 \n ;;; Accept established connections\n" +
|
||||||
|
" chain=input action=accept connection-state=established,related\n\n" +
|
||||||
|
" 1 \n ;;; YouTube bypass traffic\n" +
|
||||||
|
" chain=forward action=accept src-address-list=youtube-bypass protocol=tcp dst-port=443\n" +
|
||||||
|
" packets=188421 bytes=14283698176\n\n" +
|
||||||
|
" 2 \n ;;; Block SSH from WAN\n" +
|
||||||
|
" chain=input action=drop in-interface=ether1-wan protocol=tcp dst-port=22\n" +
|
||||||
|
" packets=882412 bytes=54618112",
|
||||||
|
|
||||||
|
"routing bgp session print": () =>
|
||||||
|
"Flags: E - established\n" +
|
||||||
|
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME\n" +
|
||||||
|
" 0 E peer-spb-01 65002 10.0.1.1 established 12d 4h 22m\n" +
|
||||||
|
" 1 E peer-fra-01 65003 10.0.2.1 established 9d 12h 11m\n" +
|
||||||
|
" 2 peer-ams-01 65004 10.0.3.1 active —",
|
||||||
|
|
||||||
|
"routing ospf neighbor print": () =>
|
||||||
|
"Flags: V - virtual\n" +
|
||||||
|
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE\n" +
|
||||||
|
" 0 10.0.0.7 Full 8 12d 4h 18m gre-msk-spb\n" +
|
||||||
|
" 1 10.0.1.1 Full 3 9d 11h 42m gre-msk-fra",
|
||||||
|
|
||||||
|
"routing bfd session print": () =>
|
||||||
|
" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME\n" +
|
||||||
|
" 0 10.200.0.1%gre-msk-spb 10.200.0.2%gre-msk-spb up 12d 4h\n" +
|
||||||
|
" 1 10.200.1.1%gre-msk-fra 10.200.1.2%gre-msk-fra up 9d 11h",
|
||||||
|
|
||||||
|
"log print": () =>
|
||||||
|
"may/01 08:14:22 ospf,debug,packet GRE-MSK-FRA: hello received, RouterID: 10.0.2.1\n" +
|
||||||
|
"may/01 08:14:21 bgp,debug peer-spb-01 sending UPDATE\n" +
|
||||||
|
"may/01 08:14:18 system,info user admin logged from 10.10.0.5\n" +
|
||||||
|
"may/01 08:12:44 firewall,info forward: in:ether2-lan out:gre-msk-fra proto TCP\n" +
|
||||||
|
"may/01 08:11:03 script,info backup-script: backup saved to /backup/mt.rsc\n" +
|
||||||
|
"may/01 08:09:17 system,warning cpu load is 85% on cpu0",
|
||||||
|
|
||||||
|
"ping": (args) => {
|
||||||
|
const host = args[0] || "8.8.8.8"
|
||||||
|
const count = parseInt(args.find(a => a.startsWith("count="))?.split("=")[1] || "4")
|
||||||
|
const rtt = Math.round(10 + Math.random() * 50)
|
||||||
|
const lines = [`PING ${host}`]
|
||||||
|
for (let i = 0; i < Math.min(count, 5); i++) {
|
||||||
|
const t = rtt + Math.round((Math.random() - 0.5) * 8)
|
||||||
|
lines.push(` seq=${i} ttl=56 time=${t}ms`)
|
||||||
|
}
|
||||||
|
lines.push(` sent=${count} received=${count} packet-loss=0%`)
|
||||||
|
return lines.join("\n")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockResolve(raw: string, serverName: string): string {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return ""
|
||||||
|
|
||||||
|
if (trimmed === "?" || trimmed === "help")
|
||||||
|
return "ip address print | ip route print | interface print\n" +
|
||||||
|
"system resource print | ip firewall filter print\n" +
|
||||||
|
"routing bgp session print | routing ospf neighbor print\n" +
|
||||||
|
"routing bfd session print | log print | ping <host>"
|
||||||
|
|
||||||
|
if (trimmed === "clear" || trimmed === "/clear") return "__CLEAR__"
|
||||||
|
if (trimmed === "quit" || trimmed === "exit") return "Connection closed."
|
||||||
|
|
||||||
|
const key = Object.keys(MOCK_COMMANDS).find(k =>
|
||||||
|
trimmed === k || trimmed.toLowerCase().startsWith(k + " ")
|
||||||
|
)
|
||||||
|
if (key) {
|
||||||
|
const rest = trimmed.slice(key.length).trim().split(/\s+/)
|
||||||
|
return MOCK_COMMANDS[key](rest, serverName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
||||||
|
const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
||||||
|
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
||||||
|
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||||
|
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
||||||
|
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
|
||||||
|
useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
|
||||||
|
// Focus only after React has committed the re-enabled input.
|
||||||
|
useEffect(()=>{
|
||||||
|
if(executing||!pendingFocus.current)return
|
||||||
|
pendingFocus.current=false
|
||||||
|
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
||||||
|
},[executing,lines])
|
||||||
|
function append(kind:TermLine["kind"],text:string) {
|
||||||
|
const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
|
||||||
|
setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
|
||||||
|
}
|
||||||
|
async function submit(){
|
||||||
|
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
||||||
|
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
||||||
|
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
||||||
|
append("prompt",server.name+" > "+command)
|
||||||
|
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
||||||
|
const controller=new AbortController();reader.current.controller=controller
|
||||||
|
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
||||||
|
try {
|
||||||
|
const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
|
||||||
|
if(!reader.current.alive)return
|
||||||
|
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
||||||
|
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
||||||
|
} catch(err) {
|
||||||
|
if(!reader.current.alive)return
|
||||||
|
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
||||||
|
append("error",message);setError(message)
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timer);lock.current=false
|
||||||
|
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||||
|
if(e.key==="Enter"){e.preventDefault();void submit()}
|
||||||
|
else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
|
||||||
|
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
||||||
|
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
||||||
|
}
|
||||||
|
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
||||||
|
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||||
|
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||||
|
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
||||||
|
</FrameHeader>
|
||||||
|
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
|
||||||
|
{lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
||||||
|
</div>
|
||||||
|
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||||
|
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||||
|
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||||
|
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||||
|
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||||
|
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const QUICK_CMDS = [
|
||||||
|
{ cmd: "system resource print", label: "system resource print" },
|
||||||
|
{ cmd: "interface print", label: "interface print" },
|
||||||
|
{ cmd: "ip address print", label: "ip address print" },
|
||||||
|
{ cmd: "ip route print", label: "ip route print" },
|
||||||
|
{ cmd: "ip firewall filter print", label: "ip firewall filter print" },
|
||||||
|
{ cmd: "routing bgp session print", label: "routing bgp session print" },
|
||||||
|
{ cmd: "routing ospf neighbor print", label: "routing ospf neighbor print" },
|
||||||
|
{ cmd: "routing ospf area print", label: "routing ospf area print" },
|
||||||
|
{ cmd: "routing bfd session print", label: "routing bfd session print" },
|
||||||
|
{ cmd: "log print", label: "log print" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Convert mock server list to TermServer[] */
|
||||||
|
function mockServersToTermServers(): TermServer[] {
|
||||||
|
return mockServers.map(s => ({
|
||||||
|
uid: s.id,
|
||||||
|
backendId: null,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status==="unknown"?null:s.status,
|
||||||
|
enabled: s.enabled,
|
||||||
|
rosVersion: s.os ?? null,
|
||||||
|
identityName: s.name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function TerminalPage(){
|
||||||
|
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||||
|
}
|
||||||
|
function TerminalRoute(){
|
||||||
|
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
|
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
||||||
|
if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
||||||
|
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
||||||
|
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||||
|
}
|
||||||
|
function TerminalContent(){
|
||||||
|
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||||
|
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
|
||||||
|
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
||||||
|
useEffect(()=>{
|
||||||
|
if(!isLive||!canRead)return
|
||||||
|
let cancelled=false;const controller=new AbortController()
|
||||||
|
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
||||||
|
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
||||||
|
setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
|
||||||
|
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
||||||
|
return()=>{cancelled=true;controller.abort()}
|
||||||
|
},[isLive,backendUrl,canRead,refresh])
|
||||||
|
const termServers=isLive?liveServers:mockServersToTermServers()
|
||||||
|
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
||||||
|
const selected=matches.length===1?matches[0]:null
|
||||||
|
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
||||||
|
const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
|
||||||
|
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
||||||
|
const blocked=!!error||loading||isLive&&!loaded
|
||||||
|
return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
|
||||||
|
header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
|
||||||
|
banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
|
||||||
|
{selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
|
||||||
|
</ServerRailLayout>
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
.container { container-type: inline-size; }
|
||||||
|
.header { display: none; }
|
||||||
|
.serverName { flex: 1 1 160px; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
|
||||||
|
}
|
||||||
|
.row > * { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.name { grid-area: name; }
|
||||||
|
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
|
||||||
|
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
|
||||||
|
.iface { grid-area: iface; }
|
||||||
|
.filter { grid-area: filter; }
|
||||||
|
.rtt { grid-area: rtt; }
|
||||||
|
.loss { grid-area: loss; }
|
||||||
|
.bt { grid-area: bt; }
|
||||||
|
.graph { grid-area: graph; }
|
||||||
|
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||||
|
.actions { grid-area: actions; }
|
||||||
|
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
|
||||||
|
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
|
||||||
|
|
||||||
|
@container (min-width: 1100px) {
|
||||||
|
.header, .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
|
||||||
|
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
|
||||||
|
}
|
||||||
|
.label, .mobile { display: none; }
|
||||||
|
.toggle, .star { justify-content: center; }
|
||||||
|
.actions button, .star button { min-width: unset; min-height: unset; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { UserSheet } from "@/components/users/user-sheet"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { servers as mockServers } from "@/lib/data"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import {
|
||||||
|
ALL_SECTIONS,
|
||||||
|
INIT_USERS,
|
||||||
|
userInitials,
|
||||||
|
type AppUser,
|
||||||
|
type AppUserForm,
|
||||||
|
type UserServerOption,
|
||||||
|
} from "@/lib/users"
|
||||||
|
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
|
import {
|
||||||
|
userApiError,
|
||||||
|
createAppUser,
|
||||||
|
getAppUser,
|
||||||
|
createUserBinding,
|
||||||
|
deleteAppUser,
|
||||||
|
deleteUserBinding,
|
||||||
|
listAppUsers,
|
||||||
|
updateAppUser,
|
||||||
|
} from "@/shared/api/users"
|
||||||
|
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
CableIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function toServerOptionsFromMock(): UserServerOption[] {
|
||||||
|
return mockServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status === "unknown" ? null : s.status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
if (!prefsHydrated) return <p role="status">Загрузка настроек пользователей…</p>
|
||||||
|
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersContent() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
const canRead = can("mm:users:read")
|
||||||
|
const canWrite = can("mm:users:write")
|
||||||
|
const canReadServers = can("mm:servers:read")
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
|
||||||
|
const [userSearch, setUserSearch] = useState("")
|
||||||
|
const visibleUsers = useMemo(() => {
|
||||||
|
const query = userSearch.trim().toLocaleLowerCase("ru-RU")
|
||||||
|
return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users
|
||||||
|
}, [users, userSearch])
|
||||||
|
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
|
||||||
|
const [loading, setLoading] = useState(isLive && canRead)
|
||||||
|
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
|
||||||
|
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||||
|
const [usersError, setUsersError] = useState<string | null>(null)
|
||||||
|
const [serversError, setServersError] = useState<string | null>(null)
|
||||||
|
const loadController = useRef<AbortController | null>(null)
|
||||||
|
const loadRevision = useRef(0)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||||
|
const [saveResume, setSaveResume] = useState(false)
|
||||||
|
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||||
|
const mutationLock = useRef(false)
|
||||||
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
|
const alive = useRef(true)
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const formReturnFocus = useRef<HTMLElement | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sheetOpen || !formReturnFocus.current) return
|
||||||
|
const label = formReturnFocus.current.getAttribute("aria-label")
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
|
||||||
|
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [sheetOpen])
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive || !canRead) return false
|
||||||
|
loadController.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
loadController.current = controller
|
||||||
|
const revision = ++loadRevision.current
|
||||||
|
const current = () => !controller.signal.aborted && revision === loadRevision.current
|
||||||
|
setLoading(true)
|
||||||
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||||
|
const [list, srvs] = await Promise.allSettled([
|
||||||
|
listAppUsers(backendUrl, { signal }).then(rows => {
|
||||||
|
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||||
|
return rows
|
||||||
|
}).catch(error => {
|
||||||
|
if (current()) setUsersError(userApiError(error))
|
||||||
|
throw error
|
||||||
|
}),
|
||||||
|
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||||
|
const rows = serverListSchema.parse(raw)
|
||||||
|
if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
|
||||||
|
if (current()) {
|
||||||
|
setServerOptions(rows.map(s => ({
|
||||||
|
id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
|
||||||
|
})))
|
||||||
|
setServersReadAt(Date.now()); setServersError(null)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}).catch(error => {
|
||||||
|
if (current()) setServersError(userApiError(error))
|
||||||
|
throw error
|
||||||
|
}) : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
if (!current()) return false
|
||||||
|
setLoading(false)
|
||||||
|
return list.status === "fulfilled" && srvs.status === "fulfilled"
|
||||||
|
}, [isLive, canRead, canReadServers, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
|
||||||
|
return () => { cancelled = true; loadController.current?.abort() }
|
||||||
|
}, [isLive, canRead, loadLive])
|
||||||
|
|
||||||
|
const usersKnown = !isLive || usersReadAt !== null
|
||||||
|
const serversKnown = !isLive || serversReadAt !== null
|
||||||
|
const writeReady = canWrite && (!isLive || (usersKnown && serversKnown && !loading && !usersError && !serversError))
|
||||||
|
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||||
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
|
const handleSave = async (form: AppUserForm) => {
|
||||||
|
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||||
|
if (!isLive) {
|
||||||
|
if (!editUser) {
|
||||||
|
const id = "u" + Date.now()
|
||||||
|
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||||
|
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||||
|
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||||
|
}
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
const checkpoint = saveCheckpoint.current ?? { user: editUser }
|
||||||
|
saveCheckpoint.current = checkpoint
|
||||||
|
setSaving(true); setSaveError(null); setOperationNotice(null)
|
||||||
|
try {
|
||||||
|
const saved = await saveUserSteps(form, checkpoint, {
|
||||||
|
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||||
|
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||||
|
read: id => getAppUser(backendUrl, id, init()),
|
||||||
|
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||||
|
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||||
|
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||||
|
}, init()),
|
||||||
|
}, current, state => {
|
||||||
|
if (!current()) return
|
||||||
|
setSaveBlocked(!!state.createUncertain)
|
||||||
|
if (state.user) {
|
||||||
|
if (!editUser) setSaveResume(true)
|
||||||
|
const confirmed = state.user
|
||||||
|
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!current() || !saved) return
|
||||||
|
setSheetOpen(false); saveCheckpoint.current = null
|
||||||
|
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||||
|
toast.success("Пользователь сохранён.")
|
||||||
|
const refreshed = await loadLive()
|
||||||
|
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (!current()) return
|
||||||
|
const detail = userApiError(error)
|
||||||
|
setSaveError(checkpoint.createUncertain
|
||||||
|
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||||
|
: checkpoint.bindingsNeedRead
|
||||||
|
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||||
|
: detail)
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget || !writeReady || mutationLock.current) return
|
||||||
|
const target = deleteTarget
|
||||||
|
if (!isLive) {
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
|
||||||
|
toast.success("Пользователь удалён в демо.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutationLock.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
mutationController.current = controller
|
||||||
|
setDeleting(true); setDeleteError(null); setOperationNotice(null)
|
||||||
|
const current = () => alive.current && !controller.signal.aborted
|
||||||
|
try {
|
||||||
|
await deleteAppUser(backendUrl, target.id, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||||
|
if (!current()) return
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== target.id))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
setOperationNotice("Удаление пользователя подтверждено API.")
|
||||||
|
toast.success("Пользователь удалён.")
|
||||||
|
const refreshed = await loadLive()
|
||||||
|
if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
|
||||||
|
} catch (error) {
|
||||||
|
if (current()) setDeleteError(userApiError(error))
|
||||||
|
} finally {
|
||||||
|
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||||
|
if (alive.current) setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-w-0">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
|
||||||
|
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||||
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
|
||||||
|
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
||||||
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
||||||
|
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
||||||
|
{!canWrite && <p role="status">Для изменения пользователей требуется mm:users:write.</p>}
|
||||||
|
{isLive && !canReadServers && <p role="status">Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.</p>}
|
||||||
|
{usersError && <div role="alert" aria-label="Пользователи" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}</p><p>{usersError}</p></div>}
|
||||||
|
{serversError && <div role="alert" aria-label="Серверы пользователей" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.</p><p>{serversError}</p></div>}
|
||||||
|
{isLive && usersReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}</p>}
|
||||||
|
{isLive && loading && !usersKnown && <p role="status">Загрузка пользователей…</p>}
|
||||||
|
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка пользователей"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Пользователи",
|
||||||
|
value: usersKnown ? users.length : "—",
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
label: "Активные",
|
||||||
|
value: usersKnown ? activeCount : "—",
|
||||||
|
icon: <UserCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
label: "Привязанные интерфейсы",
|
||||||
|
value: usersKnown ? boundCount : "—",
|
||||||
|
icon: <CableIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||||
|
{canRead && (usersKnown || loading) && <DataPageCard>
|
||||||
|
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||||
|
<UsersDataGrid
|
||||||
|
users={visibleUsers}
|
||||||
|
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||||
|
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||||
|
serversCount={serversKnown ? serverOptions.length : null}
|
||||||
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
|
isLoading={loading && !usersKnown}
|
||||||
|
actionsDisabled={!writeReady || saving || deleting}
|
||||||
|
onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||||
|
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||||
|
/>
|
||||||
|
</DataPageCard>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserSheet
|
||||||
|
returnFocus={formReturnFocus}
|
||||||
|
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||||
|
open={sheetOpen}
|
||||||
|
user={editUser}
|
||||||
|
users={users}
|
||||||
|
servers={serverOptions}
|
||||||
|
isLive={isLive}
|
||||||
|
backendUrl={backendUrl}
|
||||||
|
saving={saving}
|
||||||
|
saveError={saveError}
|
||||||
|
saveBlocked={saveBlocked}
|
||||||
|
saveResume={saveResume}
|
||||||
|
onCheckCatalogs={() => void loadLive()}
|
||||||
|
onSave={(f) => { void handleSave(f) }}
|
||||||
|
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<TrashIcon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||||
|
<AlertDialogCancel disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(null) }}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction type="button" disabled={deleting || !writeReady} aria-busy={deleting} variant="destructive" onClick={() => { void handleDelete() }}>
|
||||||
|
{deleting ? "Удаление…" : "Удалить"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
|
||||||
|
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import {
|
||||||
|
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { generateVxlanRsc } from "@/lib/vxlan-export"
|
||||||
|
import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"] | null
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VxlanApiResponse {
|
||||||
|
failures?: Array<{ serverId: string; serverName?: string; error: string }>
|
||||||
|
tunnels: VxlanTunnel[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ExportSheet({
|
||||||
|
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
tunnel: VxlanTunnel | null
|
||||||
|
onClose: () => void
|
||||||
|
serverById: Record<string, Server>
|
||||||
|
receivedAt: string | null
|
||||||
|
stale: boolean
|
||||||
|
mock: boolean
|
||||||
|
}) {
|
||||||
|
const code = useMemo(
|
||||||
|
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||||
|
[tunnel, serverById],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeExportSheet
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||||
|
title="Экспорт VXLAN"
|
||||||
|
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||||
|
formats={[
|
||||||
|
{
|
||||||
|
id: "rsc",
|
||||||
|
label: "MikroTik .rsc",
|
||||||
|
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
|
||||||
|
selectedServerId: string
|
||||||
|
selectedTunnelId: string | null
|
||||||
|
allTunnelsHref: string
|
||||||
|
onSelectServer: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||||
|
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||||
|
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false)
|
||||||
|
const loadEpoch = useRef(0)
|
||||||
|
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
const epoch = ++loadEpoch.current
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [tunnelsRes, serversRes] = await Promise.all([
|
||||||
|
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
||||||
|
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
const parsedTunnels = vxlanResponseSchema.safeParse(tunnelsRes)
|
||||||
|
const parsedServers = vxlanServersSchema.safeParse(serversRes)
|
||||||
|
if (!parsedTunnels.success || !parsedServers.success) throw new Error("Некорректный ответ загрузки VXLAN")
|
||||||
|
const nextTunnels = parsedTunnels.data.tunnels
|
||||||
|
const nextServers = parsedServers.data
|
||||||
|
const nextFailures = parsedTunnels.data.failures
|
||||||
|
const failedIds = new Set(nextFailures.map(f => f.serverId))
|
||||||
|
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
|
||||||
|
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||||
|
setFailures(nextFailures)
|
||||||
|
setLiveError(null)
|
||||||
|
setHasLoaded(true)
|
||||||
|
const at = new Date().toISOString()
|
||||||
|
setResponseReceipt(at)
|
||||||
|
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||||
|
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
} catch (e) {
|
||||||
|
if (epoch !== loadEpoch.current) return
|
||||||
|
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
|
} finally {
|
||||||
|
if (epoch === loadEpoch.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveTunnels([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId = selectedServerId
|
||||||
|
|
||||||
|
const scopedTunnels = useMemo(() => {
|
||||||
|
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === effectiveServerId && t.id === selectedTunnelId)
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
|
||||||
|
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
|
||||||
|
}, [displayTunnels, effectiveServerId, selectedTunnelId])
|
||||||
|
|
||||||
|
const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0]
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
|
||||||
|
}))
|
||||||
|
), [displayServers, displayTunnels, isLive])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return scopedTunnels
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return scopedTunnels.filter((t) =>
|
||||||
|
t.name.includes(q) ||
|
||||||
|
String(t.vni).includes(q) ||
|
||||||
|
t.vtepIp.includes(q) ||
|
||||||
|
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
|
||||||
|
)
|
||||||
|
}, [search, scopedTunnels, serverById])
|
||||||
|
|
||||||
|
const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures])
|
||||||
|
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
|
||||||
|
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
|
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={railItems}
|
||||||
|
selectedId={effectiveServerId}
|
||||||
|
preserveSelection
|
||||||
|
onSelect={onSelectServer}
|
||||||
|
showAll
|
||||||
|
allCount={displayServers.length}
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Badge variant="outline">Только просмотр</Badge>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
|
||||||
|
<ContextTitle>Не все устройства ответили</ContextTitle>
|
||||||
|
<ContextDescription>
|
||||||
|
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
|
||||||
|
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
|
||||||
|
</ContextDescription>
|
||||||
|
</ContextAlert>}
|
||||||
|
{selectedTunnelId !== null && (
|
||||||
|
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
|
||||||
|
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
|
||||||
|
<ContextDescription>
|
||||||
|
<p className="wrap-anywhere">{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</ContextDescription>
|
||||||
|
</ContextAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{isLive && liveError && selectedTunnelId === null && (
|
||||||
|
<Alert variant="warning" className="py-2">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
|
||||||
|
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
|
||||||
|
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
|
||||||
|
</div>}
|
||||||
|
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
||||||
|
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
||||||
|
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
На опрошенных серверах нет VXLAN-интерфейсов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === "mock" && (
|
||||||
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
|
Моковые данные
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка VXLAN"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "tunnels",
|
||||||
|
label: "Туннелей",
|
||||||
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "up",
|
||||||
|
label: "Активных",
|
||||||
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vni",
|
||||||
|
label: "VNI",
|
||||||
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверов",
|
||||||
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||||
|
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">
|
||||||
|
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||||
|
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||||||
|
/>
|
||||||
|
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||||||
|
tunnels={filtered}
|
||||||
|
failedServerIds={failedServerIds}
|
||||||
|
servers={displayServers}
|
||||||
|
onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
|
||||||
|
/>}
|
||||||
|
</DataPageCard>
|
||||||
|
|
||||||
|
<OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс, известный MTU и статические VTEP выбранного туннеля. IP-адреса, bridge и FDB в него не входят. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
|
||||||
|
</OpsPanel>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
<ExportSheet
|
||||||
|
open={!!exportSnapshot}
|
||||||
|
tunnel={exportSnapshot?.tunnel ?? null}
|
||||||
|
onClose={() => setExportSnapshot(null)}
|
||||||
|
serverById={exportSnapshot?.servers ?? {}}
|
||||||
|
receivedAt={exportSnapshot?.at ?? null}
|
||||||
|
stale={exportSnapshot?.stale ?? false}
|
||||||
|
mock={exportSnapshot?.mock ?? !isLive}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VxlanRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const params = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
|
const tunnelId = params.get("tunnel")
|
||||||
|
const rest = new URLSearchParams(params.toString())
|
||||||
|
rest.delete("tunnel")
|
||||||
|
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
|
||||||
|
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||||||
|
const next = new URLSearchParams(rest)
|
||||||
|
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||||
|
else next.set("server", id)
|
||||||
|
router.push("/vxlan" + (next.size ? "?" + next.toString() : ""))
|
||||||
|
}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VxlanPage() {
|
||||||
|
return <Suspense fallback={<div role="status">Загрузка VXLAN…</div>}><VxlanRoute /></Suspense>
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export default function AccessDeniedPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||||
|
необходимых прав. Обратитесь к администратору auth-portal.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/auth/callback"
|
||||||
|
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Войти снова
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/backup", {
|
||||||
|
method: "GET",
|
||||||
|
forwardRequestBody: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
clearPortalHandoffFlag,
|
||||||
|
clearToken,
|
||||||
|
can,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
parseHashToken,
|
||||||
|
permissionForPath,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
|
setToken,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const error = params.get("error")
|
||||||
|
const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => {
|
||||||
|
const permission = permissionForPath(pathname)
|
||||||
|
return !permission || can(permission)
|
||||||
|
}, firstAllowedPath())
|
||||||
|
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
|
if (accessToken) {
|
||||||
|
setToken(accessToken)
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes("mm")) {
|
||||||
|
setMessage("Нет доступа к приложению")
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/config", {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore network — proceed */
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = returnPath()
|
||||||
|
if (next === "/access-denied") {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getToken() && getClaims()) {
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
if (!getClaims()!.apps.includes("mm")) {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(returnPath())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = new URL(`${window.location.origin}/auth/callback`)
|
||||||
|
if (params.get("next")) callback.searchParams.set("next", params.get("next")!)
|
||||||
|
const ok = redirectToPortalLogin(callback.toString())
|
||||||
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||||
|
.network-map-focusable {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.network-map-focus-ring {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.network-map-focusable:focus-visible > .network-map-focus-ring {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-invert: var(--invert);
|
||||||
|
--color-invert-foreground: var(--invert-foreground);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Light theme ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Base — subtle blue-gray tint so cards (white) float above the page */
|
||||||
|
--background: oklch(0.973 0.004 247.858);
|
||||||
|
--foreground: oklch(0.141 0.006 264.0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.006 264.0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.006 264.0);
|
||||||
|
|
||||||
|
/* Primary — dark charcoal for buttons / key UI */
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
/* Secondary / muted — very light blue-gray */
|
||||||
|
--secondary: oklch(0.958 0.005 247.858);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.958 0.005 247.858);
|
||||||
|
--muted-foreground: oklch(0.520 0.010 264.0);
|
||||||
|
|
||||||
|
/* Accent — soft blue highlight */
|
||||||
|
--accent: oklch(0.942 0.016 264.376);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: var(--color-red-800);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-900);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-900);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-900);
|
||||||
|
--invert: var(--color-zinc-900);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
|
/* Borders + inputs */
|
||||||
|
--border: oklch(0.904 0.006 264.0);
|
||||||
|
--input: oklch(0.904 0.006 264.0);
|
||||||
|
|
||||||
|
/* Focus ring — blue, matching the brand accent */
|
||||||
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
|
|
||||||
|
/* ── Colorful chart palette (light) ── */
|
||||||
|
--chart-1: oklch(0.546 0.215 264.376); /* blue */
|
||||||
|
--chart-2: oklch(0.527 0.154 150.069); /* emerald */
|
||||||
|
--chart-3: oklch(0.625 0.162 41.500); /* orange */
|
||||||
|
--chart-4: oklch(0.591 0.224 296.400); /* violet */
|
||||||
|
--chart-5: oklch(0.577 0.245 27.325); /* red */
|
||||||
|
|
||||||
|
/* ── Semantic line colors for charts (light) ── */
|
||||||
|
--chart-line-1: oklch(0.527 0.154 150.069); /* green — MSK / RX */
|
||||||
|
--chart-line-2: oklch(0.546 0.215 264.376); /* blue — SPB / TX */
|
||||||
|
--chart-line-3: oklch(0.625 0.162 41.500); /* orange — FRA */
|
||||||
|
--chart-line-4: oklch(0.591 0.224 296.400); /* violet — AMS */
|
||||||
|
--chart-rx: oklch(0.527 0.154 150.069);
|
||||||
|
--chart-tx: oklch(0.546 0.215 264.376);
|
||||||
|
|
||||||
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
/* ── Sidebar — intentionally dark even in light mode ── */
|
||||||
|
--sidebar: oklch(0.152 0.010 264.376);
|
||||||
|
--sidebar-foreground: oklch(0.920 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.240 0.012 264.376);
|
||||||
|
--sidebar-accent-foreground: oklch(0.950 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
|
|
||||||
|
/* ── Status ── */
|
||||||
|
--status-online: oklch(0.527 0.154 150.069);
|
||||||
|
--status-offline: oklch(0.577 0.245 27.325);
|
||||||
|
--status-degraded: oklch(0.666 0.179 58.318);
|
||||||
|
/* badge variants — foreground text + translucent fill */
|
||||||
|
--status-online-fg: oklch(0.437 0.149 150.069); /* emerald-700 */
|
||||||
|
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
|
||||||
|
--status-offline-fg: oklch(0.505 0.213 27.325); /* red-700 */
|
||||||
|
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
|
||||||
|
--status-degraded-fg: oklch(0.555 0.163 58.318); /* amber-700 */
|
||||||
|
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Base — blue-tinted dark for modern network terminal aesthetic */
|
||||||
|
--background: oklch(0.133 0.011 264.376);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.191 0.011 264.376);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.191 0.011 264.376);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
/* Primary — bright white for dark backgrounds */
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
|
/* Secondary / muted — dark blue-gray */
|
||||||
|
--secondary: oklch(0.252 0.010 264.376);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.252 0.010 264.376);
|
||||||
|
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||||
|
|
||||||
|
/* Accent — slightly lighter blue-tinted dark */
|
||||||
|
--accent: oklch(0.285 0.022 264.376);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: var(--color-red-600);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-600);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-600);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-600);
|
||||||
|
--invert: var(--color-zinc-700);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
|
|
||||||
|
/* ── Colorful chart palette (dark — brighter for visibility) ── */
|
||||||
|
--chart-1: oklch(0.637 0.222 264.376); /* bright blue */
|
||||||
|
--chart-2: oklch(0.696 0.170 151.328); /* bright emerald */
|
||||||
|
--chart-3: oklch(0.713 0.176 47.600); /* bright orange */
|
||||||
|
--chart-4: oklch(0.714 0.173 303.900); /* bright violet */
|
||||||
|
--chart-5: oklch(0.704 0.191 22.216); /* bright red */
|
||||||
|
|
||||||
|
/* ── Semantic line colors (dark) ── */
|
||||||
|
--chart-line-1: oklch(0.696 0.170 151.328); /* green */
|
||||||
|
--chart-line-2: oklch(0.637 0.222 264.376); /* blue */
|
||||||
|
--chart-line-3: oklch(0.713 0.176 47.600); /* orange */
|
||||||
|
--chart-line-4: oklch(0.714 0.173 303.900); /* violet */
|
||||||
|
--chart-rx: oklch(0.696 0.170 151.328);
|
||||||
|
--chart-tx: oklch(0.637 0.222 264.376);
|
||||||
|
|
||||||
|
/* ── Sidebar — slightly lighter than bg in dark mode ── */
|
||||||
|
--sidebar: oklch(0.191 0.011 264.376);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
|
|
||||||
|
/* Status — same dot colors, dark-mode-adjusted badge text */
|
||||||
|
--status-online: oklch(0.527 0.154 150.069);
|
||||||
|
--status-offline: oklch(0.577 0.245 27.325);
|
||||||
|
--status-degraded: oklch(0.666 0.179 58.318);
|
||||||
|
/* badge variants — lighter fg for dark bg */
|
||||||
|
--status-online-fg: oklch(0.696 0.170 151.328); /* emerald-400 */
|
||||||
|
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
|
||||||
|
--status-offline-fg: oklch(0.704 0.191 22.216); /* red-400 */
|
||||||
|
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
|
||||||
|
--status-degraded-fg: oklch(0.769 0.149 70.080); /* amber-400 */
|
||||||
|
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
/* next-themes кладёт класс dark на <html> — без этого нативные контролы (select) дают светлый popup и белый текст */
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Нативный <select>: в Chrome/Edge выпадающая часть может игнорировать тёмный фон страницы.
|
||||||
|
Явные цвета + color-scheme выше дают читаемые пункты в светлой и тёмной теме.
|
||||||
|
*/
|
||||||
|
select {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
select option {
|
||||||
|
background-color: var(--popover);
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "MikrotikManager",
|
||||||
|
description: "MikroTik network management platform",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="ru"
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">
|
||||||
|
<ThemeProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
storage/backups/
|
||||||
|
storage/geoip/
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS deps
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
|
COPY backend/package.json backend/
|
||||||
|
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||||
|
RUN node -e "\
|
||||||
|
const fs=require('fs');\
|
||||||
|
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||||
|
p.dependencies={};\
|
||||||
|
p.devDependencies={};\
|
||||||
|
delete p.scripts;\
|
||||||
|
p.workspaces=['packages/*','backend'];\
|
||||||
|
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||||
|
"
|
||||||
|
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||||
|
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||||
|
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||||
|
&& npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
COPY packages/contracts packages/contracts
|
||||||
|
COPY backend backend
|
||||||
|
RUN npm run build -w @mmapp/contracts \
|
||||||
|
&& npm run build -w mikrotik-manager-backend \
|
||||||
|
&& npm prune --omit=dev \
|
||||||
|
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||||
|
&& mkdir -p backend/node_modules
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
# Keep system packages independent of application changes and version tags.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||||
|
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /app/data
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8000
|
||||||
|
ENV DATABASE_PATH=/app/data/mikrotik.db
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||||
|
COPY --from=build /app/backend/dist ./backend/dist
|
||||||
|
COPY --from=build /app/backend/dist-operator ./backend/dist-operator
|
||||||
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
|
COPY --from=build /app/backend/mcp-coverage.json ./backend/mcp-coverage.json
|
||||||
|
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||||
|
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||||
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["node", "backend/dist/index.js"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit"
|
||||||
|
import { config } from "dotenv"
|
||||||
|
|
||||||
|
config()
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||||
|
-- Applied once at backend boot via src/db/migrate.ts
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 443,
|
||||||
|
username TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'home-router'
|
||||||
|
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||||
|
site TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
asn TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||||
|
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id BIGINT,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||||
|
last_datagram_at TIMESTAMPTZ,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||||
|
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
bot_token TEXT NOT NULL DEFAULT '',
|
||||||
|
chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
message_thread_id INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||||
|
account_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||||
|
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||||
|
hour INTEGER NOT NULL DEFAULT 3,
|
||||||
|
minute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
week_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
month_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||||
|
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||||
|
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_source_finished_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_migration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
sqlite_imported_at TIMESTAMPTZ,
|
||||||
|
sqlite_path TEXT,
|
||||||
|
sqlite_sha256 TEXT,
|
||||||
|
report_json JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
community TEXT NOT NULL,
|
||||||
|
community_name TEXT,
|
||||||
|
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||||
|
gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_address TEXT NOT NULL,
|
||||||
|
gateway TEXT NOT NULL,
|
||||||
|
distance INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scope INTEGER,
|
||||||
|
target_scope INTEGER,
|
||||||
|
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||||
|
check_gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
polled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||||
|
latency_ms DOUBLE PRECISION,
|
||||||
|
ros_version TEXT,
|
||||||
|
board_name TEXT,
|
||||||
|
uptime TEXT,
|
||||||
|
cpu_load INTEGER,
|
||||||
|
free_memory BIGINT,
|
||||||
|
total_memory BIGINT,
|
||||||
|
identity_name TEXT,
|
||||||
|
raw_interfaces JSONB,
|
||||||
|
raw_ip_addresses JSONB,
|
||||||
|
PRIMARY KEY (id, polled_at)
|
||||||
|
) PARTITION BY RANGE (polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ok BOOLEAN NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
proto INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
out_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
next_hop TEXT NOT NULL DEFAULT '',
|
||||||
|
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, dim, key)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rtt_ms INTEGER,
|
||||||
|
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||||
|
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||||
|
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
board_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ros_version TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||||
|
last_error TEXT,
|
||||||
|
last_ping_rtt_ms INTEGER,
|
||||||
|
last_ping_loss_pct INTEGER,
|
||||||
|
last_ping_at TIMESTAMPTZ,
|
||||||
|
last_ping_error TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
probe_id TEXT,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
src_address TEXT,
|
||||||
|
dst_address TEXT,
|
||||||
|
src_interface_address TEXT,
|
||||||
|
dst_interface_address TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
ping_rtt_ms INTEGER,
|
||||||
|
ping_loss_pct INTEGER,
|
||||||
|
ping_error TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||||
|
step TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
cert_name TEXT NOT NULL,
|
||||||
|
domain_names JSONB NOT NULL,
|
||||||
|
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||||
|
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||||
|
server_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown_override TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||||
|
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
confirm_stability_sec INTEGER,
|
||||||
|
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||||
|
recovery_stability_sec INTEGER,
|
||||||
|
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
condition_line TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||||
|
scope_key TEXT PRIMARY KEY,
|
||||||
|
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_payload_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||||
|
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||||
|
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||||
|
rule_id TEXT PRIMARY KEY,
|
||||||
|
payload_hash TEXT NOT NULL,
|
||||||
|
since_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
rule_name TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
fired_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dedupe_key TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_key TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
finished_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_json JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
source_module TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
payload_json JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||||
|
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename <> 'schema_migrations'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 443,
|
||||||
|
username TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'home-router'
|
||||||
|
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||||
|
site TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
asn TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||||
|
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id BIGINT,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||||
|
last_datagram_at TIMESTAMPTZ,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||||
|
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
bot_token TEXT NOT NULL DEFAULT '',
|
||||||
|
chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
message_thread_id INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||||
|
account_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||||
|
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||||
|
hour INTEGER NOT NULL DEFAULT 3,
|
||||||
|
minute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
week_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
month_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||||
|
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||||
|
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_source_finished_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_migration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
sqlite_imported_at TIMESTAMPTZ,
|
||||||
|
sqlite_path TEXT,
|
||||||
|
sqlite_sha256 TEXT,
|
||||||
|
report_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
community TEXT NOT NULL,
|
||||||
|
community_name TEXT,
|
||||||
|
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||||
|
gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_address TEXT NOT NULL,
|
||||||
|
gateway TEXT NOT NULL,
|
||||||
|
distance INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scope INTEGER,
|
||||||
|
target_scope INTEGER,
|
||||||
|
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||||
|
check_gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
polled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||||
|
latency_ms DOUBLE PRECISION,
|
||||||
|
ros_version TEXT,
|
||||||
|
board_name TEXT,
|
||||||
|
uptime TEXT,
|
||||||
|
cpu_load INTEGER,
|
||||||
|
free_memory BIGINT,
|
||||||
|
total_memory BIGINT,
|
||||||
|
identity_name TEXT,
|
||||||
|
raw_interfaces JSONB COMPRESSION lz4,
|
||||||
|
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||||
|
PRIMARY KEY (id, polled_at)
|
||||||
|
) PARTITION BY RANGE (polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flags SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ok BOOLEAN NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
PRIMARY KEY (server_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
src INET NOT NULL,
|
||||||
|
dst INET NOT NULL,
|
||||||
|
proto SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
out_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
next_hop INET,
|
||||||
|
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, dim, key)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||||
|
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rtt_ms INTEGER,
|
||||||
|
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||||
|
PRIMARY KEY (probe_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||||
|
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||||
|
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||||
|
last_error TEXT,
|
||||||
|
last_ping_rtt_ms INTEGER,
|
||||||
|
last_ping_loss_pct INTEGER,
|
||||||
|
last_ping_at TIMESTAMPTZ,
|
||||||
|
last_ping_error TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
probe_id TEXT,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
src_address TEXT,
|
||||||
|
dst_address TEXT,
|
||||||
|
src_interface_address TEXT,
|
||||||
|
dst_interface_address TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
ping_rtt_ms INTEGER,
|
||||||
|
ping_loss_pct INTEGER,
|
||||||
|
ping_error TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||||
|
step TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
cert_name TEXT NOT NULL,
|
||||||
|
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||||
|
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||||
|
server_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown_override TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||||
|
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
confirm_stability_sec INTEGER,
|
||||||
|
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||||
|
recovery_stability_sec INTEGER,
|
||||||
|
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
condition_line TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||||
|
scope_key TEXT PRIMARY KEY,
|
||||||
|
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_payload_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||||
|
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||||
|
rule_id TEXT PRIMARY KEY,
|
||||||
|
payload_hash TEXT NOT NULL,
|
||||||
|
since_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
rule_name TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
fired_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dedupe_key TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_key TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
finished_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
source_module TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
payload_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||||
|
|
||||||
|
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- S3-compatible storage for RouterOS backups
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||||
|
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||||
|
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||||
|
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_test_at TIMESTAMPTZ,
|
||||||
|
last_test_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO backup_storage_settings (id)
|
||||||
|
VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD CONSTRAINT backup_entries_storage_check
|
||||||
|
CHECK (storage IN ('local', 's3', 'both'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS geoip_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
|
||||||
|
last_check_at TIMESTAMPTZ,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT,
|
||||||
|
country_build_at TIMESTAMPTZ,
|
||||||
|
asn_build_at TIMESTAMPTZ,
|
||||||
|
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO geoip_settings (id)
|
||||||
|
VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||||
|
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||||
|
-- задаются на листовых партициях в ensurePartitionFor.
|
||||||
|
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
iface TEXT NOT NULL,
|
||||||
|
country CHAR(2) NOT NULL,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
asn INTEGER NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
iface TEXT NOT NULL,
|
||||||
|
country CHAR(2) NOT NULL,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
asn INTEGER NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
|
||||||
|
-- Retention — prune в сервисе (последние 50 на пару server+section).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
section TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
|
||||||
|
ON config_revisions (server_id, section, created_at DESC);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
|
||||||
|
-- Needed to rebuild facts with the same internet dest as the network map.
|
||||||
|
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются.
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check;
|
||||||
|
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check
|
||||||
|
CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed'));
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE alert_outbox ADD COLUMN IF NOT EXISTS resolution_version integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_resolution_version_check CHECK (resolution_version >= 0);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- A failed command is an attempt, not a measured packet-loss percentage.
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS traffic_collection_state (
|
||||||
|
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
server_updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
last_succeeded_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
|
||||||
|
-- On a busy existing installation, pre-create this same index CONCURRENTLY
|
||||||
|
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS router_log_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
|
||||||
|
);
|
||||||
|
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
|
-- No foreign key: collected history survives device deletion.
|
||||||
|
CREATE TABLE IF NOT EXISTS router_log_collection_state (
|
||||||
|
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
|
||||||
|
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
|
||||||
|
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
|
||||||
|
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS router_log_entries (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
|
||||||
|
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
|
||||||
|
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
|
||||||
|
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL, generation INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_settings (
|
||||||
|
id integer PRIMARY KEY CHECK(id=1), enabled boolean NOT NULL DEFAULT false,
|
||||||
|
requests_retention_days integer NOT NULL DEFAULT 30 CHECK(requests_retention_days BETWEEN 1 AND 365),
|
||||||
|
audit_retention_days integer NOT NULL DEFAULT 180 CHECK(audit_retention_days BETWEEN 30 AND 3650),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
INSERT INTO mcp_settings(id) VALUES(1) ON CONFLICT DO NOTHING;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_connections (
|
||||||
|
id uuid PRIMARY KEY, name text NOT NULL, owner_id text NOT NULL, owner_permissions jsonb NOT NULL,
|
||||||
|
policy jsonb NOT NULL, generation text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_credentials (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||||
|
digest text NOT NULL UNIQUE, kind text NOT NULL CHECK(kind IN ('bearer','access','refresh')),
|
||||||
|
expires_at timestamptz NOT NULL, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
family_id uuid, client_id text, audience text
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_credentials_connection ON mcp_credentials(connection_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_audit (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, actor_id text NOT NULL, action text NOT NULL,
|
||||||
|
connection_id uuid, payload jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_audit_time ON mcp_audit(created_at,id);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_requests (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid REFERENCES mcp_connections(id) ON DELETE SET NULL,
|
||||||
|
actor_id text, tool text NOT NULL, server_ids bigint[] NOT NULL DEFAULT '{}',
|
||||||
|
status text NOT NULL, input jsonb NOT NULL DEFAULT '{}', result jsonb, error_code text,
|
||||||
|
duration_ms integer, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_requests_connection_time ON mcp_requests(connection_id,created_at,id);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_requests_server ON mcp_requests USING gin(server_ids);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_protections (
|
||||||
|
id uuid PRIMARY KEY, object_ref jsonb NOT NULL, reason text NOT NULL DEFAULT '',
|
||||||
|
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_plans (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id), operation_id text NOT NULL,
|
||||||
|
operation_version integer NOT NULL, policy_version integer NOT NULL, generation text NOT NULL,
|
||||||
|
payload jsonb NOT NULL, evidence jsonb NOT NULL, state text NOT NULL, expires_at timestamptz NOT NULL,
|
||||||
|
approved_by text, approved_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_plans_connection ON mcp_plans(connection_id,created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_operations (
|
||||||
|
id uuid PRIMARY KEY, plan_id uuid NOT NULL UNIQUE REFERENCES mcp_plans(id),
|
||||||
|
connection_id uuid NOT NULL REFERENCES mcp_connections(id), idempotency_key text NOT NULL,
|
||||||
|
state text NOT NULL DEFAULT 'pending', verification text NOT NULL DEFAULT 'pending',
|
||||||
|
result jsonb, error_code text, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||||
|
UNIQUE(connection_id,idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_operation_steps (
|
||||||
|
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||||
|
server_id bigint, state text NOT NULL, before_state jsonb, intended_state jsonb, result jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||||
|
PRIMARY KEY(operation_id,step_index)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_budget_buckets (
|
||||||
|
connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||||
|
kind text NOT NULL, bucket timestamptz NOT NULL, used integer NOT NULL CHECK(used>=0),
|
||||||
|
PRIMARY KEY(connection_id,kind,bucket)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_read_leases (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_grants (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||||
|
payload jsonb NOT NULL, revoked_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_secrets (
|
||||||
|
id uuid PRIMARY KEY, purpose text NOT NULL, server_id bigint, ciphertext text NOT NULL,
|
||||||
|
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_artifacts (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||||
|
filename text NOT NULL, media_type text NOT NULL, ciphertext text NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
|
||||||
|
id text PRIMARY KEY, name text NOT NULL, redirect_uris jsonb NOT NULL, enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_oauth_requests (
|
||||||
|
id uuid PRIMARY KEY, client_id text NOT NULL REFERENCES mcp_oauth_clients(id),
|
||||||
|
redirect_uri text NOT NULL, challenge text NOT NULL, state text, resource text NOT NULL,
|
||||||
|
scope text NOT NULL, expires_at timestamptz NOT NULL, consumed_at timestamptz,
|
||||||
|
connection_id uuid REFERENCES mcp_connections(id), code_digest text UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_routing_profiles (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||||
|
version integer NOT NULL DEFAULT 1, config jsonb NOT NULL, state jsonb NOT NULL DEFAULT '{}',
|
||||||
|
enabled boolean NOT NULL DEFAULT false, grant_expires_at timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_drift (
|
||||||
|
id uuid PRIMARY KEY, operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||||
|
server_id bigint, expected jsonb NOT NULL, observed jsonb NOT NULL,
|
||||||
|
acknowledged_at timestamptz, detected_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_routing_observations (
|
||||||
|
id uuid PRIMARY KEY, src_server_id bigint NOT NULL, dst_server_id bigint NOT NULL,
|
||||||
|
src_interface text NOT NULL, dst_interface text NOT NULL, family text NOT NULL CHECK(family IN ('ipv4','ipv6')),
|
||||||
|
routing_table text NOT NULL DEFAULT 'main', ping_ms double precision, loss_pct double precision,
|
||||||
|
dl_mbps double precision, ul_mbps double precision, ping_at timestamptz, bandwidth_at timestamptz,
|
||||||
|
context_verified boolean NOT NULL DEFAULT false, source text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), CHECK(ping_ms IS NULL OR ping_ms>=0),
|
||||||
|
CHECK(loss_pct IS NULL OR loss_pct BETWEEN 0 AND 100), CHECK(dl_mbps IS NULL OR dl_mbps>=0), CHECK(ul_mbps IS NULL OR ul_mbps>=0)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_routing_observations_leg ON mcp_routing_observations(src_server_id,dst_server_id,src_interface,family,routing_table,created_at DESC);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS credential_id uuid REFERENCES mcp_credentials(id);
|
||||||
|
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS generation text;
|
||||||
|
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS policy_version integer;
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_routing_profiles_enabled ON mcp_routing_profiles(updated_at) WHERE enabled;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS mcp_drift_open_step ON mcp_drift(operation_id,(expected->>'stepIndex')) WHERE acknowledged_at IS NULL;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_valid_until timestamptz NOT NULL DEFAULT now();
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_operation_checkpoints (
|
||||||
|
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||||
|
before_sealed text NOT NULL, after_sealed text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY(operation_id,step_index)
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_management_channels (
|
||||||
|
id uuid PRIMARY KEY, server_id bigint NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
config_sealed text NOT NULL, identity_hash text NOT NULL, attestation text NOT NULL,
|
||||||
|
created_by text NOT NULL, generation text NOT NULL, expires_at timestamptz NOT NULL,
|
||||||
|
verified_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_path_checks (
|
||||||
|
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||||
|
profile_hash text NOT NULL, candidate_id text NOT NULL, status text NOT NULL CHECK(status IN ('passed','failed','unknown')),
|
||||||
|
evidence jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_path_checks_latest ON mcp_path_checks(connection_id,profile_hash,candidate_id,created_at DESC);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_recovery_intents (
|
||||||
|
operation_id uuid PRIMARY KEY REFERENCES mcp_operations(id), state text NOT NULL,
|
||||||
|
authority_profile_id uuid NOT NULL REFERENCES mcp_routing_profiles(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_unauthorized_buckets (
|
||||||
|
bucket timestamptz NOT NULL, client_hash text NOT NULL, reason text NOT NULL,
|
||||||
|
attempts integer NOT NULL DEFAULT 1, PRIMARY KEY(bucket,client_hash,reason)
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE user_interface_bindings DROP CONSTRAINT IF EXISTS user_interface_bindings_interface_type_check;
|
||||||
|
ALTER TABLE user_interface_bindings ADD CONSTRAINT user_interface_bindings_interface_type_check CHECK(interface_type IN ('ether','bridge','vlan','vxlan','gre','wg','ipsec','other'));
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_owner_revocations (
|
||||||
|
owner_id text PRIMARY KEY, invalid_before timestamptz NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_portal_events (
|
||||||
|
id uuid PRIMARY KEY, owner_id text NOT NULL, payload_hash text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_portal_events_time ON mcp_portal_events(created_at);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_claims_issued_at timestamptz NOT NULL DEFAULT to_timestamp(0);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE INDEX IF NOT EXISTS mcp_audit_handshake_cooldown
|
||||||
|
ON mcp_audit(connection_id, created_at DESC)
|
||||||
|
WHERE action = 'connection.handshake.start';
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_credentials_readiness_expired
|
||||||
|
ON mcp_credentials(connection_id, expires_at)
|
||||||
|
WHERE client_id = 'mcp-readiness-probe';
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_grants_credential
|
||||||
|
ON mcp_grants(connection_id, (payload->>'credentialId'));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE mcp_artifacts ADD COLUMN IF NOT EXISTS access_scope jsonb;
|
||||||
|
CREATE INDEX IF NOT EXISTS mcp_artifacts_expiry ON mcp_artifacts(expires_at);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE servers ADD COLUMN IF NOT EXISTS routing_endpoint_epoch uuid NOT NULL DEFAULT gen_random_uuid();
|
||||||
|
CREATE OR REPLACE FUNCTION mm_routing_endpoint_epoch() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF ROW(NEW.host,NEW.port,NEW.username,NEW.password,NEW.use_ssl,NEW.verify_ssl,NEW.enabled)
|
||||||
|
IS DISTINCT FROM ROW(OLD.host,OLD.port,OLD.username,OLD.password,OLD.use_ssl,OLD.verify_ssl,OLD.enabled) THEN
|
||||||
|
NEW.routing_endpoint_epoch := gen_random_uuid();
|
||||||
|
ELSE
|
||||||
|
NEW.routing_endpoint_epoch := OLD.routing_endpoint_epoch;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
DROP TRIGGER IF EXISTS mm_routing_endpoint_epoch ON servers;
|
||||||
|
CREATE TRIGGER mm_routing_endpoint_epoch BEFORE UPDATE ON servers FOR EACH ROW EXECUTE FUNCTION mm_routing_endpoint_epoch();
|
||||||
|
ALTER TABLE mcp_routing_observations ADD COLUMN IF NOT EXISTS source_proof text;
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='mcp_routing_observation_source_proof' AND conrelid='mcp_routing_observations'::regclass) THEN
|
||||||
|
ALTER TABLE mcp_routing_observations ADD CONSTRAINT mcp_routing_observation_source_proof CHECK(source_proof IS NULL OR source_proof ~ '^[a-f0-9]{64}$');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
-- Existing measurements stay readable but unsigned. Never attest old samples
|
||||||
|
-- using today's endpoint: that would invent capture-time provenance.
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
-- Service-owned exact read-back must survive a subsequent network probe failure.
|
||||||
|
-- Generic after_sealed remains an array of resource snapshots.
|
||||||
|
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_sealed text;
|
||||||
|
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_proof text;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS mcp_scoped_recovery_intents (
|
||||||
|
source_operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||||
|
source_step_index integer NOT NULL CHECK(source_step_index>=0),
|
||||||
|
source_receipt_id uuid NOT NULL,
|
||||||
|
source_configuration_proof text NOT NULL,
|
||||||
|
recovery_receipt_id uuid NOT NULL UNIQUE,
|
||||||
|
recovery_operation_id uuid REFERENCES mcp_operations(id),
|
||||||
|
mode text NOT NULL CHECK(mode IN ('manual','reserved')),
|
||||||
|
authority_profile_id uuid REFERENCES mcp_routing_profiles(id),
|
||||||
|
authority_profile_version integer,
|
||||||
|
intent_sealed text NOT NULL,
|
||||||
|
intent_proof text NOT NULL,
|
||||||
|
state text NOT NULL CHECK(state IN ('intent','verified','unknown')),
|
||||||
|
deadline timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
verified_sealed text,
|
||||||
|
verified_proof text,
|
||||||
|
PRIMARY KEY(source_operation_id,source_step_index),
|
||||||
|
CHECK(mode='manual' OR authority_profile_id IS NOT NULL AND authority_profile_version IS NOT NULL),
|
||||||
|
CHECK((state='verified')=(verified_sealed IS NOT NULL AND verified_proof IS NOT NULL))
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Immutable commit evidence shared by ingestion and REST/MCP fact rebuild.
|
||||||
|
-- No raw flow payload, IP addresses, credentials or notification contents belong here.
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_write_receipts (
|
||||||
|
batch_id uuid PRIMARY KEY,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('ingest', 'facts', 'rebuild')),
|
||||||
|
source_proof text NOT NULL CHECK (source_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
result jsonb NOT NULL CHECK (jsonb_typeof(result) = 'object' AND octet_length(result::text) <= 65536),
|
||||||
|
committed_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- Receipts are intentionally not pruned: an owned unknown batch may still need them.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- A claim survives unknown results and is never recycled for another attempt.
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_settings_intents (
|
||||||
|
receipt_id uuid NOT NULL,
|
||||||
|
phase text NOT NULL CHECK (phase IN ('configuration', 'listener')),
|
||||||
|
request_proof text NOT NULL,
|
||||||
|
state text NOT NULL DEFAULT 'claimed' CHECK (state IN ('claimed', 'completed')),
|
||||||
|
result jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (receipt_id, phase)
|
||||||
|
);
|
||||||
|
INSERT INTO traffic_flow_settings(id) VALUES(1) ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Precondition receipts only. No executor capability is granted by this table.
|
||||||
|
CREATE TABLE IF NOT EXISTS vrf_production_tickets (
|
||||||
|
ticket_id uuid PRIMARY KEY,
|
||||||
|
plan_id uuid NOT NULL UNIQUE REFERENCES mcp_plans(id),
|
||||||
|
connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||||
|
server_id bigint NOT NULL REFERENCES servers(id),
|
||||||
|
owner_id text NOT NULL,
|
||||||
|
action_id text NOT NULL CHECK (action_id IN ('ros.vrf.create','ros.vrf.update','ros.vrf.delete')),
|
||||||
|
action_version integer NOT NULL CHECK (action_version > 0),
|
||||||
|
source_proof text NOT NULL CHECK (source_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
digest text NOT NULL CHECK (digest ~ '^[a-f0-9]{64}$'),
|
||||||
|
ciphertext text NOT NULL,
|
||||||
|
state text NOT NULL DEFAULT 'issued' CHECK (state IN ('issued','claimed')),
|
||||||
|
issued_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
claimed_at timestamptz,
|
||||||
|
CHECK (expires_at > issued_at AND expires_at <= issued_at + interval '5 minutes'),
|
||||||
|
CHECK ((state='issued' AND claimed_at IS NULL) OR (state='claimed' AND claimed_at IS NOT NULL))
|
||||||
|
);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Durable diagnostic attempts; this migration registers no MCP action.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE server_probe_executions (
|
||||||
|
receipt_id uuid PRIMARY KEY, binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')),
|
||||||
|
execution_id uuid NOT NULL, actor_id text NOT NULL CHECK(length(actor_id) BETWEEN 1 AND 200),
|
||||||
|
authentication text NOT NULL CHECK(authentication IN ('mcp','jwt','local')),
|
||||||
|
mcp_connection_id uuid REFERENCES mcp_connections(id), mcp_credential_id uuid REFERENCES mcp_credentials(id), request_id text,
|
||||||
|
authority_issued_at bigint NOT NULL CHECK(authority_issued_at>=0), authority_expires_at timestamptz NOT NULL,
|
||||||
|
source_server_id integer NOT NULL CHECK(source_server_id>0), destination_server_id integer CHECK(destination_server_id>0),
|
||||||
|
tool text NOT NULL CHECK(tool IN ('ping','traceroute','bandwidth','dns','route','mtu')),
|
||||||
|
state text NOT NULL DEFAULT 'claimed' CHECK(state IN ('claimed','completed','failed','cancelled')),
|
||||||
|
attempted_stages integer CHECK(attempted_stages BETWEEN 0 AND 11), completed_stages integer CHECK(completed_stages BETWEEN 0 AND attempted_stages),
|
||||||
|
receipt_ciphertext text CHECK(octet_length(receipt_ciphertext)<=262144), receipt_proof text CHECK(receipt_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), finished_at timestamptz,
|
||||||
|
UNIQUE(identity_kind,execution_id),
|
||||||
|
CHECK((identity_kind='mcp' AND authentication='mcp' AND mcp_connection_id IS NOT NULL AND mcp_credential_id IS NOT NULL AND request_id IS NULL)
|
||||||
|
OR (identity_kind='rest' AND authentication IN ('jwt','local') AND mcp_connection_id IS NULL AND mcp_credential_id IS NULL AND request_id IS NOT NULL AND length(request_id) BETWEEN 1 AND 200)),
|
||||||
|
CHECK((tool='bandwidth')=(destination_server_id IS NOT NULL)),
|
||||||
|
CHECK((state='claimed' AND receipt_ciphertext IS NULL AND receipt_proof IS NULL AND finished_at IS NULL AND attempted_stages IS NULL AND completed_stages IS NULL)
|
||||||
|
OR (state<>'claimed' AND receipt_ciphertext IS NOT NULL AND receipt_proof IS NOT NULL AND finished_at IS NOT NULL AND attempted_stages IS NOT NULL AND completed_stages IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE TABLE server_probe_execution_stages (
|
||||||
|
receipt_id uuid NOT NULL REFERENCES server_probe_executions(receipt_id), stage_index integer NOT NULL CHECK(stage_index BETWEEN 1 AND 11),
|
||||||
|
kind text NOT NULL CHECK(kind IN ('source-dns-A','native-ping','native-traceroute','native-bandwidth','native-route-read',
|
||||||
|
'manager-dns-A','manager-dns-AAAA','manager-dns-MX','manager-dns-NS','manager-dns-TXT','manager-dns-CNAME','manager-dns-PTR',
|
||||||
|
'native-mtu-1500','native-mtu-1492','native-mtu-1480','native-mtu-1476','native-mtu-1472','native-mtu-1468','native-mtu-1400','native-mtu-1280','native-mtu-1024','native-mtu-576')),
|
||||||
|
timeout_ms integer NOT NULL CHECK(timeout_ms BETWEEN 1 AND 120000),
|
||||||
|
state text NOT NULL DEFAULT 'intent' CHECK(state IN ('intent','received')),
|
||||||
|
outcome text CHECK(outcome IN ('received','source-unavailable')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), received_at timestamptz,
|
||||||
|
PRIMARY KEY(receipt_id,stage_index),
|
||||||
|
CHECK((state='intent' AND outcome IS NULL AND received_at IS NULL) OR (state='received' AND outcome IS NOT NULL AND received_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE TABLE server_probe_execution_quarantines (
|
||||||
|
receipt_id uuid PRIMARY KEY, binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')), execution_id uuid NOT NULL,
|
||||||
|
identity_proof text NOT NULL CHECK(identity_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
error_code text NOT NULL CHECK(error_code ~ '^[A-Z_]{1,64}$'),
|
||||||
|
attempted_stages integer NOT NULL CHECK(attempted_stages BETWEEN 0 AND 11), completed_stages integer NOT NULL CHECK(completed_stages BETWEEN 0 AND attempted_stages),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp(), UNIQUE(identity_kind,execution_id)
|
||||||
|
);
|
||||||
|
CREATE FUNCTION server_probe_execution_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'probe execution journal is append only'; END IF;
|
||||||
|
IF OLD.state<>'claimed' OR (to_jsonb(OLD)-ARRAY['state','attempted_stages','completed_stages','receipt_ciphertext','receipt_proof','finished_at']) IS DISTINCT FROM
|
||||||
|
(to_jsonb(NEW)-ARRAY['state','attempted_stages','completed_stages','receipt_ciphertext','receipt_proof','finished_at']) OR NEW.state='claimed'
|
||||||
|
THEN RAISE EXCEPTION 'probe execution identity or terminal receipt is immutable'; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=OLD.receipt_id) THEN RAISE EXCEPTION 'probe execution quarantined'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
CREATE TRIGGER server_probe_execution_immutable BEFORE UPDATE OR DELETE ON server_probe_executions FOR EACH ROW EXECUTE FUNCTION server_probe_execution_immutable();
|
||||||
|
CREATE FUNCTION server_probe_stage_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'probe stage journal is append only'; END IF;
|
||||||
|
PERFORM receipt_id FROM server_probe_executions WHERE receipt_id=NEW.receipt_id AND state='claimed' FOR UPDATE;
|
||||||
|
IF NOT FOUND OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=NEW.receipt_id) THEN RAISE EXCEPTION 'probe attempt is not executable'; END IF;
|
||||||
|
IF TG_OP='INSERT' AND (NEW.stage_index<>(SELECT COALESCE(max(stage_index),0)+1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id AND state<>'received'))
|
||||||
|
THEN RAISE EXCEPTION 'probe stages must be serial and contiguous'; END IF;
|
||||||
|
IF TG_OP='UPDATE' AND (OLD.state<>'intent' OR NEW.state<>'received' OR (to_jsonb(OLD)-ARRAY['state','outcome','received_at']) IS DISTINCT FROM (to_jsonb(NEW)-ARRAY['state','outcome','received_at']))
|
||||||
|
THEN RAISE EXCEPTION 'probe stage terminal state is immutable'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
CREATE TRIGGER server_probe_stage_immutable BEFORE INSERT OR UPDATE OR DELETE ON server_probe_execution_stages FOR EACH ROW EXECUTE FUNCTION server_probe_stage_immutable();
|
||||||
|
CREATE FUNCTION server_probe_quarantine_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'probe quarantine is immutable'; END $$;
|
||||||
|
CREATE TRIGGER server_probe_quarantine_immutable BEFORE UPDATE OR DELETE ON server_probe_execution_quarantines FOR EACH ROW EXECUTE FUNCTION server_probe_quarantine_immutable();
|
||||||
|
|
||||||
|
INSERT INTO schema_migrations(id) VALUES ('0035_server_probe_journal') ON CONFLICT (id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Durable stop requests for opt-in diagnostics, not configuration rollback.
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE mcp_diagnostic_cancellations (
|
||||||
|
operation_id uuid PRIMARY KEY REFERENCES mcp_operations(id),
|
||||||
|
connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||||
|
actor_id text NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 200),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
CREATE FUNCTION mcp_diagnostic_cancellation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN RAISE EXCEPTION 'diagnostic cancellation request is immutable'; END $$;
|
||||||
|
CREATE TRIGGER mcp_diagnostic_cancellation_immutable BEFORE UPDATE OR DELETE ON mcp_diagnostic_cancellations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION mcp_diagnostic_cancellation_immutable();
|
||||||
|
INSERT INTO schema_migrations(id) VALUES ('0036_diagnostic_cancellations') ON CONFLICT (id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
-- Fresh-only enrollment of empty diagnostic tables. No historical backfill,
|
||||||
|
-- source-generation claim, public registration or automatic cleanup is granted.
|
||||||
|
BEGIN;
|
||||||
|
LOCK TABLE server_probe_executions,server_probe_execution_stages,server_probe_execution_quarantines,mcp_diagnostic_cancellations IN ACCESS EXCLUSIVE MODE;
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS(SELECT 1 FROM server_probe_executions)
|
||||||
|
OR EXISTS(SELECT 1 FROM server_probe_execution_stages)
|
||||||
|
OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines)
|
||||||
|
OR EXISTS(SELECT 1 FROM mcp_diagnostic_cancellations)
|
||||||
|
THEN RAISE EXCEPTION 'nonempty legacy diagnostic history prevents fresh storage enrollment'; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM pg_catalog.pg_class WHERE relname IN
|
||||||
|
('server_probe_storage_usage','server_probe_storage_admissions','server_probe_execution_payloads'))
|
||||||
|
THEN RAISE EXCEPTION 'unmarked diagnostic storage relation prevents fresh storage enrollment'; END IF;
|
||||||
|
IF NOT EXISTS(SELECT 1 FROM mcp_settings WHERE id=1 AND requests_retention_days BETWEEN 1 AND 365)
|
||||||
|
THEN RAISE EXCEPTION 'authoritative probe retention setting unavailable'; END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE server_probe_storage_usage (
|
||||||
|
scope text PRIMARY KEY CHECK(scope='global' OR (scope LIKE 'owner:%' AND length(scope) BETWEEN 7 AND 206)),
|
||||||
|
admissions bigint NOT NULL DEFAULT 0 CHECK(admissions>=0),
|
||||||
|
reserved_files bigint NOT NULL DEFAULT 0 CHECK(reserved_files>=0 AND reserved_files<=admissions),
|
||||||
|
reserved_bytes bigint NOT NULL DEFAULT 0 CHECK(reserved_bytes>=0),
|
||||||
|
CHECK(reserved_bytes=admissions*32768+reserved_files*262144)
|
||||||
|
);
|
||||||
|
CREATE TABLE server_probe_storage_admissions (
|
||||||
|
receipt_id uuid PRIMARY KEY,
|
||||||
|
owner_id text NOT NULL CHECK(length(owner_id) BETWEEN 1 AND 200),
|
||||||
|
identity_kind text NOT NULL CHECK(identity_kind IN ('mcp','rest')), execution_id uuid NOT NULL,
|
||||||
|
binding_proof text NOT NULL CHECK(binding_proof ~ '^[a-f0-9]{64}$'),
|
||||||
|
authority_expires_at timestamptz NOT NULL,
|
||||||
|
claim_token uuid, claim_attempted_at timestamptz,
|
||||||
|
purged_at timestamptz, cancelled_at timestamptz, created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
retention_days integer NOT NULL CHECK(retention_days BETWEEN 1 AND 365),
|
||||||
|
safety_ms integer NOT NULL DEFAULT 60000 CHECK(safety_ms BETWEEN 60000 AND 86400000),
|
||||||
|
gc_not_before timestamptz NOT NULL DEFAULT 'infinity',
|
||||||
|
CHECK(gc_not_before>=authority_expires_at+make_interval(days=>retention_days)+safety_ms*interval '1 millisecond'),
|
||||||
|
UNIQUE(identity_kind,execution_id),
|
||||||
|
CHECK(claim_token IS NULL OR (claim_attempted_at IS NULL AND purged_at IS NULL AND cancelled_at IS NULL)),
|
||||||
|
CHECK(cancelled_at IS NULL OR (claim_token IS NULL AND claim_attempted_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE INDEX server_probe_storage_admissions_owner_age ON server_probe_storage_admissions(owner_id,authority_expires_at,receipt_id);
|
||||||
|
CREATE TABLE server_probe_execution_payloads (
|
||||||
|
receipt_id uuid PRIMARY KEY REFERENCES server_probe_executions(receipt_id),
|
||||||
|
receipt_ciphertext text NOT NULL CHECK(octet_length(receipt_ciphertext) BETWEEN 1 AND 262144),
|
||||||
|
receipt_proof text NOT NULL CHECK(receipt_proof ~ '^[a-f0-9]{64}$')
|
||||||
|
);
|
||||||
|
INSERT INTO server_probe_storage_usage(scope,admissions,reserved_files,reserved_bytes) VALUES('global',0,0,0);
|
||||||
|
|
||||||
|
-- PostgreSQL drops table CHECKs dependent on this column together with it.
|
||||||
|
-- The locked empty-table guard precedes this column removal; no data is copied.
|
||||||
|
ALTER TABLE server_probe_executions DROP COLUMN receipt_ciphertext;
|
||||||
|
ALTER TABLE server_probe_executions ADD CONSTRAINT server_probe_storage_terminal_state CHECK(
|
||||||
|
(state='claimed' AND receipt_proof IS NULL AND finished_at IS NULL AND attempted_stages IS NULL AND completed_stages IS NULL)
|
||||||
|
OR(state<>'claimed' AND receipt_proof IS NOT NULL AND finished_at IS NOT NULL AND attempted_stages IS NOT NULL AND completed_stages IS NOT NULL));
|
||||||
|
|
||||||
|
-- Predicate only: it performs no DELETE. Every guarded exception below must
|
||||||
|
-- independently satisfy the persisted lifetime, actual current retention and
|
||||||
|
-- original terminal operation. The GUC alone never grants cleanup authority.
|
||||||
|
CREATE FUNCTION server_probe_storage_gc_allowed(id uuid) RETURNS boolean LANGUAGE plpgsql SET TimeZone='UTC' AS $$
|
||||||
|
DECLARE a server_probe_storage_admissions%ROWTYPE; activity timestamptz; current_retention integer; terminal_at timestamptz;
|
||||||
|
BEGIN
|
||||||
|
IF current_setting('mm.probe_storage_gc',true) IS DISTINCT FROM 'on' THEN RETURN false; END IF;
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR a.gc_not_before>=clock_timestamp() OR a.authority_expires_at>=clock_timestamp() THEN RETURN false; END IF;
|
||||||
|
SELECT settings.requests_retention_days INTO current_retention FROM mcp_settings settings WHERE settings.id=1;
|
||||||
|
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RETURN false; END IF;
|
||||||
|
terminal_at:=a.created_at;
|
||||||
|
IF a.identity_kind='mcp' THEN
|
||||||
|
SELECT GREATEST(o.finished_at,p.expires_at,COALESCE(cancellation.created_at,o.finished_at)) INTO terminal_at
|
||||||
|
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
|
||||||
|
JOIN mcp_connections owner_connection ON owner_connection.id=o.connection_id
|
||||||
|
LEFT JOIN mcp_diagnostic_cancellations cancellation ON cancellation.operation_id=o.id
|
||||||
|
WHERE o.id=a.execution_id AND owner_connection.owner_id=a.owner_id AND p.operation_id='servers.probes.run'
|
||||||
|
AND o.state IN ('applied','failed','unknown','rejected','partial','cancelled') AND o.finished_at IS NOT NULL
|
||||||
|
AND (cancellation.operation_id IS NULL OR(cancellation.connection_id=o.connection_id AND cancellation.actor_id=a.owner_id))
|
||||||
|
FOR UPDATE OF o,p,owner_connection;
|
||||||
|
IF NOT FOUND OR terminal_at IS NULL THEN RETURN false; END IF;
|
||||||
|
END IF;
|
||||||
|
SELECT GREATEST(a.created_at,a.authority_expires_at,terminal_at,COALESCE(a.cancelled_at,a.created_at),
|
||||||
|
COALESCE((SELECT finished_at FROM server_probe_executions WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT created_at FROM server_probe_execution_quarantines WHERE receipt_id=id),a.created_at),
|
||||||
|
COALESCE((SELECT max(GREATEST(created_at,COALESCE(received_at,created_at))) FROM server_probe_execution_stages WHERE receipt_id=id),a.created_at)) INTO activity;
|
||||||
|
RETURN activity+make_interval(days=>GREATEST(a.retention_days,current_retention))+a.safety_ms*interval '1 millisecond'<clock_timestamp();
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION server_probe_storage_admission_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE current_retention integer; activity timestamptz;
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||||
|
RAISE EXCEPTION 'probe admission lifetime has not elapsed';
|
||||||
|
END IF;
|
||||||
|
SELECT requests_retention_days INTO current_retention FROM mcp_settings WHERE id=1;
|
||||||
|
IF current_retention IS NULL OR current_retention NOT BETWEEN 1 AND 365 THEN RAISE EXCEPTION 'probe retention unavailable'; END IF;
|
||||||
|
IF TG_OP='INSERT' THEN
|
||||||
|
IF NEW.purged_at IS NOT NULL OR (NEW.cancelled_at IS NULL AND
|
||||||
|
(NEW.authority_expires_at<=clock_timestamp() OR NEW.claim_token IS NULL OR NEW.claim_attempted_at IS NOT NULL))
|
||||||
|
OR (NEW.cancelled_at IS NOT NULL AND (NEW.claim_token IS NOT NULL OR NEW.claim_attempted_at IS NULL))
|
||||||
|
THEN RAISE EXCEPTION 'invalid fresh probe admission'; END IF;
|
||||||
|
IF NEW.cancelled_at IS NOT NULL THEN NEW.cancelled_at:=clock_timestamp(); NEW.claim_attempted_at:=NEW.cancelled_at; END IF;
|
||||||
|
NEW.retention_days:=current_retention; RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF (to_jsonb(OLD)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at']) IS DISTINCT FROM
|
||||||
|
(to_jsonb(NEW)-ARRAY['claim_token','claim_attempted_at','purged_at','cancelled_at'])
|
||||||
|
THEN RAISE EXCEPTION 'immutable probe admission identity'; END IF;
|
||||||
|
IF OLD.cancelled_at IS DISTINCT FROM NEW.cancelled_at THEN
|
||||||
|
IF OLD.cancelled_at IS NULL AND NEW.cancelled_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||||
|
AND NEW.purged_at IS NOT DISTINCT FROM OLD.purged_at AND NEW.claim_attempted_at IS NOT NULL
|
||||||
|
AND (OLD.claim_attempted_at IS NULL OR NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at)
|
||||||
|
THEN NEW.cancelled_at:=clock_timestamp(); IF OLD.claim_attempted_at IS NULL THEN NEW.claim_attempted_at:=NEW.cancelled_at; END IF; RETURN NEW; END IF;
|
||||||
|
RAISE EXCEPTION 'probe cancellation is irreversible';
|
||||||
|
END IF;
|
||||||
|
IF OLD.purged_at IS NULL AND NEW.purged_at IS NULL AND OLD.claim_token IS NOT NULL AND NEW.claim_token IS NULL
|
||||||
|
AND OLD.cancelled_at IS NULL AND OLD.claim_attempted_at IS NULL AND NEW.claim_attempted_at IS NOT NULL AND OLD.authority_expires_at>clock_timestamp()
|
||||||
|
THEN RETURN NEW; END IF;
|
||||||
|
IF OLD.purged_at IS NULL AND NEW.purged_at IS NOT NULL AND NEW.claim_token IS NULL
|
||||||
|
AND NEW.claim_attempted_at IS NOT DISTINCT FROM OLD.claim_attempted_at THEN
|
||||||
|
SELECT GREATEST(OLD.authority_expires_at,COALESCE(e.finished_at,OLD.authority_expires_at),COALESCE(q.created_at,OLD.authority_expires_at)) INTO activity
|
||||||
|
FROM (SELECT 1) x LEFT JOIN server_probe_executions e ON e.receipt_id=OLD.receipt_id
|
||||||
|
LEFT JOIN server_probe_execution_quarantines q ON q.receipt_id=OLD.receipt_id;
|
||||||
|
IF activity<clock_timestamp()-make_interval(days=>GREATEST(OLD.retention_days,current_retention)) THEN RETURN NEW; END IF;
|
||||||
|
END IF;
|
||||||
|
RAISE EXCEPTION 'probe admission transition refused';
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_storage_admission_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_storage_admissions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_admission_guard();
|
||||||
|
|
||||||
|
CREATE FUNCTION server_probe_storage_anchor_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE a server_probe_storage_admissions%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR a.owner_id<>NEW.actor_id OR a.identity_kind<>NEW.identity_kind OR a.execution_id<>NEW.execution_id
|
||||||
|
OR a.binding_proof<>NEW.binding_proof OR a.authority_expires_at<>NEW.authority_expires_at
|
||||||
|
OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||||
|
THEN RAISE EXCEPTION 'probe anchor has no live consumed admission'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_storage_anchor_guard BEFORE INSERT OR UPDATE ON server_probe_executions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_anchor_guard();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION server_probe_execution_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||||
|
RAISE EXCEPTION 'probe execution lifetime has not elapsed';
|
||||||
|
END IF;
|
||||||
|
IF OLD.state<>'claimed' OR (to_jsonb(OLD)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) IS DISTINCT FROM
|
||||||
|
(to_jsonb(NEW)-ARRAY['state','attempted_stages','completed_stages','receipt_proof','finished_at']) OR NEW.state='claimed'
|
||||||
|
THEN RAISE EXCEPTION 'probe execution identity or terminal receipt is immutable'; END IF;
|
||||||
|
IF EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=OLD.receipt_id)
|
||||||
|
THEN RAISE EXCEPTION 'probe execution quarantined'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
|
||||||
|
CREATE FUNCTION server_probe_storage_stage_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND purged_at IS NULL AND cancelled_at IS NULL
|
||||||
|
AND claim_token IS NULL AND claim_attempted_at IS NOT NULL AND authority_expires_at>clock_timestamp() FOR UPDATE;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'probe stage has no live admission'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
CREATE TRIGGER server_probe_storage_stage_guard BEFORE INSERT OR UPDATE ON server_probe_execution_stages
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_stage_guard();
|
||||||
|
|
||||||
|
CREATE FUNCTION server_probe_storage_quarantine_guard() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id AND identity_kind=NEW.identity_kind
|
||||||
|
AND execution_id=NEW.execution_id AND binding_proof=NEW.binding_proof FOR UPDATE;
|
||||||
|
IF NOT FOUND THEN RAISE EXCEPTION 'probe quarantine has no committed matching admission'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
CREATE TRIGGER server_probe_storage_quarantine_guard BEFORE INSERT ON server_probe_execution_quarantines
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_quarantine_guard();
|
||||||
|
|
||||||
|
CREATE FUNCTION server_probe_storage_payload_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE a server_probe_storage_admissions%ROWTYPE; e server_probe_executions%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_advisory_xact_lock(hashtextextended('server-probe-storage-v2',0));
|
||||||
|
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'probe payload is immutable'; END IF;
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
PERFORM receipt_id FROM server_probe_storage_admissions WHERE receipt_id=OLD.receipt_id AND purged_at IS NOT NULL FOR UPDATE;
|
||||||
|
IF NOT FOUND AND NOT server_probe_storage_gc_allowed(OLD.receipt_id) THEN RAISE EXCEPTION 'probe payload is not purged'; END IF;
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO a FROM server_probe_storage_admissions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR a.purged_at IS NOT NULL OR a.cancelled_at IS NOT NULL OR a.claim_token IS NOT NULL OR a.claim_attempted_at IS NULL OR a.authority_expires_at<=clock_timestamp()
|
||||||
|
THEN RAISE EXCEPTION 'probe payload has no live admission'; END IF;
|
||||||
|
SELECT * INTO e FROM server_probe_executions WHERE receipt_id=NEW.receipt_id FOR UPDATE;
|
||||||
|
IF NOT FOUND OR e.state='claimed' OR e.receipt_proof<>NEW.receipt_proof OR e.actor_id<>a.owner_id OR e.binding_proof<>a.binding_proof
|
||||||
|
OR e.identity_kind<>a.identity_kind OR e.execution_id<>a.execution_id OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines q
|
||||||
|
WHERE q.receipt_id=NEW.receipt_id OR(q.identity_kind=a.identity_kind AND q.execution_id=a.execution_id))
|
||||||
|
THEN RAISE EXCEPTION 'probe payload terminal identity mismatch or quarantine'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER server_probe_storage_payload_guard BEFORE INSERT OR UPDATE OR DELETE ON server_probe_execution_payloads
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION server_probe_storage_payload_guard();
|
||||||
|
-- Preserve every original INSERT/UPDATE rule; only the already-validated
|
||||||
|
-- lifetime predicate can admit DELETE; no cleanup is scheduled by migration.
|
||||||
|
CREATE OR REPLACE FUNCTION server_probe_stage_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
IF server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||||
|
RAISE EXCEPTION 'probe stage lifetime has not elapsed';
|
||||||
|
END IF;
|
||||||
|
PERFORM receipt_id FROM server_probe_executions WHERE receipt_id=NEW.receipt_id AND state='claimed' FOR UPDATE;
|
||||||
|
IF NOT FOUND OR EXISTS(SELECT 1 FROM server_probe_execution_quarantines WHERE receipt_id=NEW.receipt_id) THEN RAISE EXCEPTION 'probe attempt is not executable'; END IF;
|
||||||
|
IF TG_OP='INSERT' AND (NEW.stage_index<>(SELECT COALESCE(max(stage_index),0)+1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id)
|
||||||
|
OR EXISTS(SELECT 1 FROM server_probe_execution_stages WHERE receipt_id=NEW.receipt_id AND state<>'received'))
|
||||||
|
THEN RAISE EXCEPTION 'probe stages must be serial and contiguous'; END IF;
|
||||||
|
IF TG_OP='UPDATE' AND (OLD.state<>'intent' OR NEW.state<>'received' OR (to_jsonb(OLD)-ARRAY['state','outcome','received_at']) IS DISTINCT FROM (to_jsonb(NEW)-ARRAY['state','outcome','received_at']))
|
||||||
|
THEN RAISE EXCEPTION 'probe stage terminal state is immutable'; END IF;
|
||||||
|
RETURN NEW; END $$;
|
||||||
|
CREATE OR REPLACE FUNCTION server_probe_quarantine_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||||
|
IF TG_OP='DELETE' AND server_probe_storage_gc_allowed(OLD.receipt_id) THEN RETURN OLD; END IF;
|
||||||
|
RAISE EXCEPTION 'probe quarantine remains immutable'; END $$;
|
||||||
|
CREATE OR REPLACE FUNCTION mcp_diagnostic_cancellation_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE original_receipt uuid;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
SELECT a.receipt_id INTO original_receipt FROM server_probe_storage_admissions a
|
||||||
|
JOIN mcp_operations o ON o.id=a.execution_id AND o.connection_id=OLD.connection_id
|
||||||
|
WHERE a.identity_kind='mcp' AND a.execution_id=OLD.operation_id AND a.owner_id=OLD.actor_id;
|
||||||
|
IF FOUND AND server_probe_storage_gc_allowed(original_receipt) THEN RETURN OLD; END IF;
|
||||||
|
END IF;
|
||||||
|
RAISE EXCEPTION 'diagnostic cancellation lifetime has not elapsed';
|
||||||
|
END $$;
|
||||||
|
INSERT INTO schema_migrations(id) VALUES ('0037_server_probe_storage_v2') ON CONFLICT (id) DO NOTHING;
|
||||||
|
COMMIT;
|
||||||
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user