Compare commits

...
21 Commits
Author SHA1 Message Date
Denozordec 8d5fd84962 fix(data-collection): добавить возможность отключения переключателей и улучшить обработку настроек
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m31s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:37:55 +07:00
Denozordec f69e65b014 fix(docker): добавить переменную окружения для внутреннего URL бэкенда
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 22:21:57 +07:00
Denozordec 49d14a00af fix(data-collection): улучшить обработку ошибок и загрузку данных с API
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 22:11:49 +07:00
Denozordec 9ab2418a5f feat(backup): добавить таблицу для резервных копий и функции для работы с ними
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m35s
Docker images / frontend-image (push) Successful in 1m42s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:56:13 +07:00
Denozordec 63aa9d424b chore(backups): удалить устаревшие резервные копии Gateway
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m38s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 21:55:41 +07:00
Denozordec 43ab17b253 feat(scheduler): добавить расписание бэкапов и автообновление сертификатов
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
2026-05-12 21:46:11 +07:00
Denozordec 4b8cf83ee9 fix(backend): улучшить управление блокировками и очистку устаревших директорий
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m34s
Docker images / frontend-image (push) Successful in 1m41s
Docker images / updater-image (push) Successful in 45s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s
2026-05-12 21:28:08 +07:00
Denozordec cdaf3deb03 chore(release): обновить заметки о релизе и манифест для версии 1.3.4
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 2m30s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 20:51:21 +07:00
Denozordec 3ce4179f3c chore(package): обновить зависимости и добавить поддержку swc для Windows 2026-05-12 20:48:45 +07:00
Denozordec 1a47905beb fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов 2026-05-12 20:43:34 +07:00
Denozordec b51d979a1c fix(backend): улучшить обработку хранилищ доверия для сертификатов 2026-05-12 20:40:56 +07:00
Denozordec bc01da1708 fix(backend): добавить функции для работы с IP-адресами и DNS-записями 2026-05-12 20:35:07 +07:00
Denozordec 500a543045 fix(backend): улучшить уникальность идентификаторов сертификатов 2026-05-12 20:29:57 +07:00
Denozordec 60024facab fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов 2026-05-12 20:29:06 +07:00
Denozordec 53ea0e74a3 fix(ui): добавить новые компоненты для управления сертификатами 2026-05-12 20:11:22 +07:00
Denozordec 6c8ca17590 fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки 2026-05-12 20:04:44 +07:00
Denozordec d62d212c86 chore(backend): добавить поддержку ACME и управление сертификатами 2026-05-12 20:00:25 +07:00
Denozordec bfceb07a23 fix(ui): убрать проверку статуса бэкенда из компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 18:58:38 +07:00
Denozordec e7a8ad0910 chore(rules): добавить локальный hook для проверки сообщений коммитов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m36s
Docker images / frontend-image (push) Successful in 2m25s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
2026-05-12 18:18:30 +07:00
Denozordec 0435608ef9 chore: обновить правила коммитов и улучшить форматирование релизных заметок
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m33s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
- Уточнены правила формирования сообщений коммитов, включая использование русского языка и уточнение типов изменений (fix/chore/feat).
- Добавлена функция для форматирования заголовков коммитов в компоненте модалки релизов, улучшая читаемость.
- Обновлены ссылки на правила коммитов в документации для большей ясности.
2026-05-12 18:06:29 +07:00
Denozordec c2a8a08618 feat: обновить название проекта и улучшить документацию
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m37s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
- Изменено название проекта с "RouterLists" на "MikrotikManager" во всех соответствующих файлах и компонентах.
- Обновлены ссылки и комментарии в документации, чтобы отразить новое название и улучшить ясность.
- Внесены изменения в правила коммитов, чтобы обеспечить использование русского языка в сообщениях коммитов.
2026-05-12 17:53:53 +07:00
95 changed files with 4844 additions and 157653 deletions
+27 -167
View File
@@ -1,190 +1,50 @@
{
"version": "1.1.0",
"tag": "v1.1.0",
"publishedAt": "2026-05-12T10:30:14.398Z",
"releaseUrl": "https://git.shts.su/denozord/MikrotikManager-3/releases/tag/v1.1.0",
"version": "1.3.4",
"tag": "v1.3.4",
"publishedAt": "2026-05-12T13:50:06.198Z",
"releaseUrl": "",
"commits": [
{
"sha": "4b92a6eb81ce64fad2ca61f5fa94342959a45349",
"shortSha": "4b92a6e",
"subject": "feat: release v1.1.0 with enhancements and refactoring",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "9ccc8459d77032dae8e7f67ac0df0287d7553341",
"shortSha": "9ccc845",
"subject": "feat: update application versioning and enhance release management",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "f282585b525fe787ab6bdbd2f83c9c848b376771",
"shortSha": "f282585",
"subject": "feat: enhance network configuration handling in updater script",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "40e2040fb05717283d0ee2b7d0a6b3315abecf73",
"shortSha": "40e2040",
"subject": "feat: refactor theme management and remove next-themes dependency",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "bb07ba0f69966e4b5533f6b4f0d7d96cef0916f1",
"shortSha": "bb07ba0",
"subject": "feat: implement database backup and restore functionality in settings page",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "1fefed2aa0dbd51f893750e2e179f65bd9386372",
"shortSha": "1fefed2",
"subject": "chore: enhance data source configuration and mock data handling",
"sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
"shortSha": "3ce4179",
"subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "4489dfb2d62e836b5415b9ead409851bef995eaf",
"shortSha": "4489dfb",
"subject": "chore: enhance frontend configuration for backend integration",
"sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
"shortSha": "1a47905",
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
"author": "Denozordec",
"type": "chore"
"type": "fix"
},
{
"sha": "4b240d70d0fbc2143cc657f78f932d003ad5d406",
"shortSha": "4b240d7",
"subject": "chore: add server and server_snapshots tables to database schema",
"sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
"shortSha": "b51d979",
"subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
"author": "Denozordec",
"type": "chore"
"type": "fix"
},
{
"sha": "63252ae85ed03b772ede2be97a27f1605cc16d0d",
"shortSha": "63252ae",
"subject": "chore: enhance image reference resolution in updater script",
"sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
"shortSha": "bc01da1",
"subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
"author": "Denozordec",
"type": "chore"
"type": "fix"
},
{
"sha": "a359874a42add6b0f4f2f4844ba6e19268bdc05f",
"shortSha": "a359874",
"subject": "chore: enhance Dockerfile for backend to rebuild better-sqlite3",
"sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
"shortSha": "500a543",
"subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
"author": "Denozordec",
"type": "chore"
"type": "fix"
},
{
"sha": "4ec247f2c02edfccd775c3e5c644c28844358c90",
"shortSha": "4ec247f",
"subject": "chore: update Docker Compose configuration for data volume management",
"sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
"shortSha": "60024fa",
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "2697a35c0a719396fbe5fdaf6045cb9ca49c28fa",
"shortSha": "2697a35",
"subject": "chore: streamline Docker workflow and enhance build efficiency",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "7295545cde2fc7b0674ba17b3447d89be2f85218",
"shortSha": "7295545",
"subject": "chore: add updater image configuration to Docker workflow",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "a5b5a2a3d390bf4f6fb0986a917d43532844a346",
"shortSha": "a5b5a2a",
"subject": "chore: enhance Dockerfiles and package.json for improved build process",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "01596864dc7801275eefcb67aad67b58785d3a28",
"shortSha": "0159686",
"subject": "chore: update Dockerfiles to ignore scripts during npm install",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "02119a3acde256a92edd06cf4d00337bb53088ee",
"shortSha": "02119a3",
"subject": "chore: configure dynamic image naming for frontend and backend in Docker workflow",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "c36994c36b78063068aff5fa509d1d26c522de51",
"shortSha": "c36994c",
"subject": "chore: update configuration and dependencies",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "dfbaa859ad7616159e373ca50632efd2ad77e8e3",
"shortSha": "dfbaa85",
"subject": "chore: isolate frontend and backend via npm workspaces and @mmapp/contracts",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "b9a75b6831e4ef27726bedbab37f385366c33067",
"shortSha": "b9a75b6",
"subject": "feat: implement internet path functionality with backend support",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "11ad94f67d4a3f5567d3e6e9cd8fe7a587e86df1",
"shortSha": "11ad94f",
"subject": "feat: integrate sonner for toast notifications and enhance UI feedback",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "84ecd4f0619cb434918e3d36d1d2d8d61dc0c759",
"shortSha": "84ecd4f",
"subject": "feat: enhance backups page with live data loading and backup job management",
"author": "Denozordec",
"type": "feat"
},
{
"sha": "6d8379501caf263fa2def609c91601d8b4bb4298",
"shortSha": "6d83795",
"subject": "refactor: replace custom API fetch logic with requestJson utility across multiple pages",
"author": "Denozordec",
"type": "refactor"
},
{
"sha": "5f31bb47fb11592ef1dd1c576105efb07cff584e",
"shortSha": "5f31bb4",
"subject": "chore: synchronize pending app/backend updates and repository hygiene",
"author": "Denozordec",
"type": "chore"
},
{
"sha": "bdb9b72fac5e9cf03faa6e8df463759a9defbf24",
"shortSha": "bdb9b72",
"subject": "Init 2",
"author": "Denozordec",
"type": "other"
},
{
"sha": "ce00c4c671834df0c3277480ecf1a7bbd03cf3f4",
"shortSha": "ce00c4c",
"subject": "Update .gitignore to include additional build and log files",
"author": "Denozordec",
"type": "other"
},
{
"sha": "f3f831653f48c24ae02d15cf10b3e929d4d63e14",
"shortSha": "f3f8316",
"subject": "Init commit",
"author": "Denozordec",
"type": "other"
"type": "fix"
}
]
}
+12 -35
View File
@@ -1,41 +1,18 @@
# v1.1.0
# v1.3.4
Версия **1.1.0**.
Версия **1.3.4**.
## feat
## fix
- feat: release v1.1.0 with enhancements and refactoring (`4b92a6e`, Denozordec)
- feat: update application versioning and enhance release management (`9ccc845`, Denozordec)
- feat: enhance network configuration handling in updater script (`f282585`, Denozordec)
- feat: refactor theme management and remove next-themes dependency (`40e2040`, Denozordec)
- feat: implement database backup and restore functionality in settings page (`bb07ba0`, Denozordec)
- feat: implement internet path functionality with backend support (`b9a75b6`, Denozordec)
- feat: integrate sonner for toast notifications and enhance UI feedback (`11ad94f`, Denozordec)
- feat: enhance backups page with live data loading and backup job management (`84ecd4f`, Denozordec)
- 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: enhance data source configuration and mock data handling (`1fefed2`, Denozordec)
- chore: enhance frontend configuration for backend integration (`4489dfb`, Denozordec)
- chore: add server and server_snapshots tables to database schema (`4b240d7`, Denozordec)
- chore: enhance image reference resolution in updater script (`63252ae`, Denozordec)
- chore: enhance Dockerfile for backend to rebuild better-sqlite3 (`a359874`, Denozordec)
- chore: update Docker Compose configuration for data volume management (`4ec247f`, Denozordec)
- chore: streamline Docker workflow and enhance build efficiency (`2697a35`, Denozordec)
- chore: add updater image configuration to Docker workflow (`7295545`, Denozordec)
- chore: enhance Dockerfiles and package.json for improved build process (`a5b5a2a`, Denozordec)
- chore: update Dockerfiles to ignore scripts during npm install (`0159686`, Denozordec)
- chore: configure dynamic image naming for frontend and backend in Docker workflow (`02119a3`, Denozordec)
- chore: update configuration and dependencies (`c36994c`, Denozordec)
- chore: isolate frontend and backend via npm workspaces and @mmapp/contracts (`dfbaa85`, Denozordec)
- chore: synchronize pending app/backend updates and repository hygiene (`5f31bb4`, Denozordec)
## refactor
- refactor: replace custom API fetch logic with requestJson utility across multiple pages (`6d83795`, Denozordec)
## other
- Init 2 (`bdb9b72`, Denozordec)
- Update .gitignore to include additional build and log files (`ce00c4c`, Denozordec)
- Init commit (`f3f8316`, Denozordec)
- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, Denozordec)
+20 -2
View File
@@ -13,6 +13,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").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"])
@@ -139,6 +140,23 @@ function getCommitsSince(tag) {
)
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())
@@ -210,7 +228,7 @@ function main() {
if (commits.length === 0) {
const deployTag = latestTag ?? `v${baseFromTag}`
const deployVersion = baseFromTag
const displayCommits = latestTag ? getCommitsSince(getPreviousTag(latestTag)) : []
const displayCommits = getRecentCommits()
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
mkdirSync(outputDir, { recursive: true })
@@ -244,7 +262,7 @@ function main() {
tag,
publishedAt,
releaseUrl,
commits: toManifestCommits(commits),
commits: toManifestCommits(getRecentCommits()),
}
mkdirSync(outputDir, { recursive: true })
+21
View File
@@ -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",
})
+62
View File
@@ -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)
+34
View File
@@ -0,0 +1,34 @@
---
description: "Generate Commit Message: русский subject, fix vs feat, Conventional Commits"
alwaysApply: true
---
# Git commit message (Generate Commit Message)
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
## Обязательно
- Subject и body — **только на русском** (после `:` в conventional commit).
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
## Тип: сначала не `feat`
- **`fix`** — баг, регрессия, правка вёрстки/модалки, некорректное поведение, CI/CD, мелкий дефект.
- **`chore` / `docs` / `ci` / `build`** — правила Cursor, README, workflow, зависимости, рефакторинг без новой UX-фичи.
- **`feat`** — только **новая** пользовательская возможность (страница, API, сценарий), которой не было.
Если сомневаешься между `feat` и `fix` — выбирай **`fix`** или **`chore`**, не `feat`.
## Примеры
- `fix(ui): выровнять модалку сводки релиза`
- `fix(ci): исправить сборку release manifest`
- `chore(rules): усилить правила русских commit message`
- `feat(releases): добавить страницу релизов`
## Запрещено
- `feat: update layout`, `fix: add modal`, `chore: update rules` — английский subject.
- `feat` для правки UI, правил, документации и багфиксов.
+1 -1
View File
@@ -27,4 +27,4 @@ alwaysApply: false
## Связь с проектом
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.mdc`**.
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
+1 -1
View File
@@ -18,7 +18,7 @@ alwaysApply: false
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
## Формат вывода (строго)
+2
View File
@@ -9,6 +9,8 @@ alwaysApply: true
**Язык:** все ответы пользователю — **только на русском**.
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
## 1. Документация в первую очередь
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
+17 -4
View File
@@ -1,18 +1,30 @@
---
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
alwaysApply: true
---
# Версионирование релизов
## Generate Commit Message (критично)
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
- Body и footer (если нужны) — **только на русском**.
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
## Patch (`1.0.x`)
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
- Scope по желанию: `fix(servers):`, `chore(ci):`.
## Minor (`1.x.0`)
@@ -23,10 +35,11 @@ alwaysApply: true
- `fix(servers): исправить опрос API после таймаута` — patch
- `feat(filters): добавить синхронизацию BGP in` — minor
- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо
- `chore(ci): добавить job prepare-release` — хорошо
- `feat: add release modal` — плохо (subject не на русском)
## Антипаттерны
WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
Подробности — раздел CI/CD в `README.md`.
+10
View File
@@ -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`.
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -e
node "$(dirname "$0")/../.ci/scripts/validate-commit-message.mjs" "$1"
+12
View File
@@ -0,0 +1,12 @@
# 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`.
+1
View File
@@ -40,6 +40,7 @@ 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
+1 -1
View File
@@ -215,7 +215,7 @@ npm --prefix backend run db:studio
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (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).
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
## Прод-развёртывание Docker
+42 -5
View File
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { toast } from "sonner"
// ─── small UI helpers ─────────────────────────────────────────────────────────
@@ -116,6 +116,7 @@ export default function BackupsPage() {
const [opBusy, setOpBusy] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [backupJobId, setBackupJobId] = useState<string | null>(null)
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
@@ -140,7 +141,29 @@ export default function BackupsPage() {
}
function handleSave() {
toast.success("Настройки сохранены")
if (scheduleSaveBusy) return
setScheduleSaveBusy(true)
setOpError(null)
void (async () => {
try {
await putBackupScheduleSettings(backendUrl, {
enabled: schedule.enabled,
frequency: schedule.frequency,
hour: schedule.hour,
minute: schedule.minute,
weekDay: schedule.weekDay,
monthDay: schedule.monthDay,
keepCount: schedule.keepCount,
format: schedule.format,
serverIds: [...selectedServers],
})
toast.success("Настройки сохранены")
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания")
} finally {
setScheduleSaveBusy(false)
}
})()
}
// Manual backup sheet
@@ -171,14 +194,28 @@ export default function BackupsPage() {
setLoading(true)
setOpError(null)
try {
const [serversRows, backupsRows] = await Promise.all([
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
listServers(backendUrl),
listBackups(backendUrl),
getBackupScheduleSettings(backendUrl),
])
const mappedServers = serversRows.map((s) => toFrontendServer(s))
setLiveServers(mappedServers)
setBackupList(backupsRows.map(mapApiBackupToUi))
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
setSchedule({
enabled: scheduleRow.enabled,
frequency: scheduleRow.frequency,
hour: scheduleRow.hour,
minute: scheduleRow.minute,
weekDay: scheduleRow.weekDay,
monthDay: scheduleRow.monthDay,
keepCount: scheduleRow.keepCount,
format: scheduleRow.format,
})
const serverIds = scheduleRow.serverIds.length > 0
? scheduleRow.serverIds
: mappedServers.map((s) => s.id)
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
} finally {
@@ -711,7 +748,7 @@ export default function BackupsPage() {
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
<Button onClick={handleSave} className="gap-2">
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
Сохранить настройки
</Button>
</div>
+2 -2
View File
@@ -826,8 +826,8 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [loading, setLoading] = useState(false)
File diff suppressed because it is too large Load Diff
+35 -8
View File
@@ -289,8 +289,8 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
export default function DashboardPage() {
const pathname = usePathname()
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
@@ -306,9 +306,10 @@ export default function DashboardPage() {
const [internetPathError, setInternetPathError] = useState<string | null>(null)
const probeServerCatalog = useMemo(() => {
if (!prefsHydrated) return []
if (!isLive) return mockServers
return liveServersResolved ?? []
}, [isLive, liveServersResolved])
}, [prefsHydrated, isLive, liveServersResolved])
const fetchProbes = useCallback(async (silent: boolean) => {
if (!isLive) return
@@ -523,13 +524,15 @@ export default function DashboardPage() {
}, [mockDashEpoch])
const activeProbesTable = useMemo(() => {
if (!prefsHydrated) return []
if (!isLive) return mockActiveProbes
if (liveProbes === null && probesLoading) return []
if (liveProbes === null) return []
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
}, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes])
const probesSubtitle = useMemo(() => {
if (!prefsHydrated) return "Загрузка…"
if (!isLive) {
const starred = mockActiveProbes.length
return starred > 0
@@ -541,10 +544,13 @@ export default function DashboardPage() {
return n > 0
? `${n} на дашборде · последний час (API)`
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
}, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
const serverStatusModel = useMemo(() => {
if (!prefsHydrated) {
return { kind: "loading" as const, subtitle: "Загрузка…" }
}
if (!isLive) {
return {
kind: "mock" as const,
@@ -571,9 +577,12 @@ export default function DashboardPage() {
servers,
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
}
}, [isLive, liveServersResolved, probesLoading, probesError])
}, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError])
const latencyChartBlock = useMemo(() => {
if (!prefsHydrated) {
return { kind: "loading" as const }
}
if (!isLive) {
return {
kind: "mock" as const,
@@ -612,7 +621,7 @@ export default function DashboardPage() {
subtitle:
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
}
}, [isLive, liveProbes, probesLoading, liveServersResolved])
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
const dashboardKpi = useMemo(() => {
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
@@ -620,6 +629,24 @@ export default function DashboardPage() {
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
const loadingKpi = (sparkColor: string) => ({
value: "—",
unit: undefined as string | undefined,
delta: "Загрузка…",
deltaDir: "up" as const,
spark: undefined as number[] | undefined,
sparkColor,
})
if (!prefsHydrated) {
return {
servers: loadingKpi("var(--chart-line-1)"),
filters: loadingKpi("var(--chart-line-2)"),
bgp: loadingKpi("var(--chart-line-4)"),
alerts: loadingKpi("var(--chart-5)"),
}
}
if (!isLive) {
const totalSrv = mockServers.length
const onlineSrv = mockServers.filter((s) => s.status === "online").length
@@ -730,7 +757,7 @@ export default function DashboardPage() {
sparkColor: "var(--chart-5)",
},
}
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
}, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
return (
<div className="flex flex-col h-full">
+390 -85
View File
@@ -24,13 +24,15 @@ import {
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { requestJson } from "@/shared/api/http-client"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot,
type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type PingRunSnapshot,
type ResourcesRunSnapshot,
type SchedulerRunSnapshot,
@@ -56,15 +58,46 @@ function makeApiFetch(backendUrl: string) {
}
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error) return reason.message
return String(reason)
}
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
const errors: string[] = []
for (let i = 0; i < results.length; i += 1) {
const result = results[i]
if (result.status === "rejected") {
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
}
}
return errors
}
function Toggle({
checked,
onChange,
disabled,
}: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
checked ? "bg-primary" : "bg-input",
)}
>
@@ -464,6 +497,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
</div>
)
}
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Проверено</dt>
<dd className="font-mono font-medium">{c.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Обновлено</dt>
<dd className="font-mono font-medium">{c.renewed}</dd>
</div>
<div>
<dt className="text-muted-foreground">Пропущено</dt>
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{c.errors.length}</dd>
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Создано</dt>
<dd className="font-mono font-medium">{b.created}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{b.failures}</dd>
</div>
<div>
<dt className="text-muted-foreground">Удалено старых</dt>
<dd className="font-mono font-medium">{b.pruned}</dd>
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
@@ -660,9 +760,41 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
)
}
type SchedulerToggleOverrides = {
trafficEnabled?: boolean
serversApiEnabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
internetPathEnabled?: boolean
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
export default function DataCollectionPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
@@ -683,6 +815,9 @@ export default function DataCollectionPage() {
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
@@ -714,32 +849,79 @@ export default function DataCollectionPage() {
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80"
const [traffic, serversApi, uptime, internetPath, runsRes] = await Promise.all([
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
])
setTrafficCollector(traffic)
setServersApiCollector(serversApi)
setUptimeCollector(uptime)
setInternetPathCollector(internetPath)
setSchedulerRuns(runsRes.runs ?? [])
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
const loadErrors = collectSettledErrors(
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
)
if (loadErrors.length > 0) {
setCollectorError(loadErrors.join("; "))
}
const traffic = readSettled(trafficRes)
if (traffic) {
setTrafficCollector(traffic)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
}
const serversApi = readSettled(serversApiRes)
if (serversApi) {
setServersApiCollector(serversApi)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
}
const uptime = readSettled(uptimeRes)
if (uptime) {
setUptimeCollector(uptime)
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
if (uptime.scheduler?.jobs?.length) {
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
}
}
const internetPath = readSettled(internetPathRes)
if (internetPath) {
setInternetPathCollector(internetPath)
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
}
const certRenew = readSettled(certRenewRes)
if (certRenew) {
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
}
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
@@ -747,6 +929,138 @@ export default function DataCollectionPage() {
}
}, [apiFetch, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: trafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: serversApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: internetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: certRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
if (saveErrors.length > 0) {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
internetPathIntervalDraft,
renewBeforeDaysDraft,
serversApiIntervalDraft,
trafficIntervalDraft,
trafficRetentionDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeRetentionDraft,
uptimeSpeedIntervalDraft,
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
overrides.trafficEnabled = nextEnabled
} else if (jobKey === "servers_rest_ping") {
setDraftServersApiEnabled(nextEnabled)
overrides.serversApiEnabled = nextEnabled
} else if (jobKey === "uptime_resources") {
setDraftResourcesEnabled(nextEnabled)
overrides.resourcesEnabled = nextEnabled
} else if (jobKey === "uptime_ping") {
setDraftPingEnabled(nextEnabled)
overrides.pingEnabled = nextEnabled
} else if (jobKey === "uptime_speed") {
setDraftSpeedEnabled(nextEnabled)
overrides.speedEnabled = nextEnabled
} else if (jobKey === "certificates_renew") {
setDraftCertRenewEnabled(nextEnabled)
overrides.certRenewEnabled = nextEnabled
} else if (jobKey === "internet_path") {
setDraftInternetPathEnabled(nextEnabled)
overrides.internetPathEnabled = nextEnabled
} else {
return
}
setCollectorError(null)
setSchedulerSaveBusy(true)
try {
await persistSchedulerDrafts(overrides)
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
useEffect(() => {
if (!isLive) {
setTrafficCollector(null)
@@ -775,20 +1089,26 @@ export default function DataCollectionPage() {
}
const enabledJobsCount = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs
if (jobs?.length) return jobs.filter((job) => job.enabled).length
let n = draftTrafficEnabled ? 1 : 0
if (draftServersApiEnabled) n += 1
if (draftResourcesEnabled) n += 1
if (draftPingEnabled) n += 1
if (draftSpeedEnabled) n += 1
if (draftInternetPathEnabled) n += 1
if (draftCertRenewEnabled) n += 1
return n
}, [
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
uptimeCollector?.scheduler?.jobs,
])
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
@@ -805,7 +1125,9 @@ export default function DataCollectionPage() {
{
label: "Включено задач",
value: `${enabledJobsCount} / ${schedulerJobCount}`,
sub: "По переключателям на этой странице (до сохранения)",
sub: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
},
{
@@ -872,7 +1194,18 @@ export default function DataCollectionPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!isLive && (
{!prefsHydrated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
<CardDescription className="text-xs">
Читаем режим данных и адрес API из локальных настроек.
</CardDescription>
</CardHeader>
</Card>
)}
{prefsHydrated && !isLive && (
<Card>
<CardHeader>
<CardTitle className="text-base">Нужен live-режим</CardTitle>
@@ -917,7 +1250,7 @@ export default function DataCollectionPage() {
<CardHeader className="border-b border-border pb-4">
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
<CardDescription className="text-xs">
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже интервалы и срок хранения.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
@@ -936,20 +1269,22 @@ export default function DataCollectionPage() {
<tbody className="divide-y divide-border">
{SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine"
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
const en = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
? draftTrafficEnabled
: jobKey === "servers_rest_ping"
? draftServersApiEnabled
: jobKey === "uptime_resources"
: jobKey === "uptime_resources"
? draftResourcesEnabled
: jobKey === "uptime_ping"
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: draftInternetPathEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -962,7 +1297,9 @@ export default function DataCollectionPage() {
? uptimeIntervalDraft
: jobKey === "uptime_speed"
? uptimeSpeedIntervalDraft
: internetPathIntervalDraft
: jobKey === "certificates_renew"
? certRenewIntervalDraft
: internetPathIntervalDraft
const setIv = fixedSchedule
? () => {}
: jobKey === "traffic"
@@ -975,7 +1312,9 @@ export default function DataCollectionPage() {
? setUptimeIntervalDraft
: jobKey === "uptime_speed"
? setUptimeSpeedIntervalDraft
: setInternetPathIntervalDraft
: jobKey === "certificates_renew"
? setCertRenewIntervalDraft
: setInternetPathIntervalDraft
const defSec = fixedSchedule
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
@@ -988,7 +1327,9 @@ export default function DataCollectionPage() {
? 15
: jobKey === "uptime_speed"
? 60
: 300
: jobKey === "certificates_renew"
? 21600
: 300
return (
<tr key={jobKey} className="hover:bg-muted/40">
<td className="px-5 py-3 align-top">
@@ -1002,14 +1343,10 @@ export default function DataCollectionPage() {
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<Toggle
checked={en}
disabled={fixedSchedule || schedulerSaveBusy}
onChange={(v) => {
if (fixedSchedule) return
if (jobKey === "traffic") setDraftTrafficEnabled(v)
else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v)
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
else setDraftInternetPathEnabled(v)
if (fixedSchedule || schedulerSaveBusy) return
void handleJobEnabledChange(jobKey, v)
}}
/>
</span>
@@ -1111,6 +1448,15 @@ export default function DataCollectionPage() {
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={renewBeforeDaysDraft}
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
@@ -1122,48 +1468,7 @@ export default function DataCollectionPage() {
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
await apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftTrafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
})
await apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftServersApiEnabled,
intervalSec: sApiInt,
}),
})
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled: draftResourcesEnabled,
pingEnabled: draftPingEnabled,
speedEnabled: draftSpeedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
})
await apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: draftInternetPathEnabled,
intervalSec: ipInt,
}),
})
await persistSchedulerDrafts()
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
+2 -2
View File
@@ -185,7 +185,7 @@ function generateCombinedRouterOSConfig(
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
const lines: string[] = [
`# RouterLists — конфиг фильтрации`,
`# MikrotikManager — конфиг фильтрации`,
`# Дата : ${now}`,
`# Серверов : ${withRules.length}`,
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
@@ -271,7 +271,7 @@ function generateCombinedRouterOSConfig(
lines.push(`/routing filter rule add \\`)
lines.push(` chain=bgp-in \\`)
lines.push(` comment="RouterLists: ${server.name}" \\`)
lines.push(` comment="MikrotikManager: ${server.name}" \\`)
lines.push(` rule="`)
lines.push(branches)
lines.push(` "`)
+1 -1
View File
@@ -417,7 +417,7 @@ function NativeSelect({ value, onChange, children, className }: {
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
const lines: string[] = []
lines.push(`# RouterLists Firewall Export`)
lines.push(`# MikrotikManager Firewall Export`)
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
lines.push(`# Правил: ${rules.length}`)
lines.push("")
+3 -3
View File
@@ -54,7 +54,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
const srv = serverById[t.serverId]
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`)
lines.push("")
// GRE interface
@@ -272,8 +272,8 @@ type PageTab = "tunnels" | "pools"
// ════════════════════════════════════════════════════════════════════════════
export default function GrePage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState<Server[]>([])
+2
View File
@@ -1,6 +1,7 @@
import { AppSidebar } from "@/components/app-sidebar"
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"
@@ -12,6 +13,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
+4 -4
View File
@@ -777,12 +777,12 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
// ════════════════════════════════════════════════════════════════════════════
export default function NetworkMapPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const useLiveData = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const useLiveData = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [mapServers, setMapServers] = useState<Server[]>(mockServers)
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>(mockGreTunnels)
const [mapServers, setMapServers] = useState<Server[]>([])
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
+13 -2
View File
@@ -1227,8 +1227,8 @@ export default function OspfPage() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all")
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
const [loading, setLoading] = useState(false)
@@ -1291,6 +1291,16 @@ export default function OspfPage() {
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
}
if (isLive) {
return {
items: [],
neighbors: [],
graphNodes: [],
graphEdges: [],
routerIds: {},
bfdSessions: [],
}
}
return {
items: MOCK_ITEMS,
neighbors: MOCK_NEIGHBORS,
@@ -1318,6 +1328,7 @@ export default function OspfPage() {
}
return [...seen.values()]
}
if (isLive) return []
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
const siteCountry: Record<string, string> = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
+2 -4
View File
@@ -729,10 +729,8 @@ export default function ProbesPage() {
const allServers = useMemo(() => {
if (!isLive) return servers
if (liveServers.length > 0) return liveServers
if (liveLoad === "error") return servers
return []
}, [isLive, liveServers, liveLoad])
return liveServers
}, [isLive, liveServers])
useEffect(() => {
if (!isLive) {
+4 -4
View File
@@ -498,12 +498,12 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
}
export default function RecursiveRoutesPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>(mockServers)
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
const [servers, setServers] = useState<Server[]>([])
const [selectedServerId, setSelectedServerId] = useState<string>("")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
const [search, setSearch] = useState("")
+9 -4
View File
@@ -3,6 +3,11 @@ import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
import {
formatCommitSubject,
getReleaseCommitPreview,
RELEASE_COMMIT_PREVIEW_LIMIT,
} from "@/lib/release-commits"
import { readReleaseManifest } from "@/lib/release-manifest"
import { cn } from "@/lib/utils"
import { ExternalLinkIcon } from "lucide-react"
@@ -24,7 +29,7 @@ export default function ReleasesPage() {
const version = getAppVersion()
const isProdBuild = !version.endsWith("-dev")
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
const commits = manifest.commits
const commits = getReleaseCommitPreview(manifest.commits)
return (
<div className="flex flex-col h-full">
@@ -50,7 +55,7 @@ export default function ReleasesPage() {
<Card>
<CardHeader>
<CardTitle>Текущая версия</CardTitle>
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
@@ -68,7 +73,7 @@ export default function ReleasesPage() {
<CardTitle>Сводка коммитов</CardTitle>
<CardDescription>
{commits.length > 0
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
</CardDescription>
</CardHeader>
@@ -84,7 +89,7 @@ export default function ReleasesPage() {
<span className="font-mono">{commit.shortSha}</span>
<span>{commit.author}</span>
</div>
<p className="mt-1 text-sm">{commit.subject}</p>
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
</li>
))}
</ul>
+4 -4
View File
@@ -321,10 +321,10 @@ type SheetMode = "add" | "edit"
// ════════════════════════════════════════════════════════════════════════════
export default function ServersPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [serverList, setServerList] = useState<Server[]>(initialServers)
const [serverList, setServerList] = useState<Server[]>([])
const [_backendOk, setBackendOk] = useState(false)
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
@@ -358,7 +358,7 @@ export default function ServersPage() {
})
.catch(() => {
setBackendOk(false)
setServerList(initialServers)
setServerList([])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, backendUrl])
+2 -2
View File
@@ -427,8 +427,8 @@ interface BackendServer {
}
export default function TerminalPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
// Server list state
const [liveServers, setLiveServers] = useState<TermServer[]>([])
+2 -2
View File
@@ -758,8 +758,8 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
]
export default function TrafficPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
+7 -13
View File
@@ -1293,15 +1293,15 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
// ── page ───────────────────────────────────────────────────────────────────────
export default function UptimePage() {
const [allServers, setAllServers] = useState<Server[]>(mockServers)
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
const liveApi = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [allServers, setAllServers] = useState<Server[]>([])
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
const [probes, setProbes] = useState<PingProbe[]>(INIT_PROBES)
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
const [probes, setProbes] = useState<PingProbe[]>([])
const [resources, setResources] = useState<ServerResource[]>([])
const [isPaused, setIsPaused] = useState(false)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
@@ -1334,18 +1334,12 @@ export default function UptimePage() {
enabled: true,
})
useEffect(() => {
if (!liveApi) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
return
}
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
}, [liveApi])
useEffect(() => {
if (liveApi) return
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}, [liveApi])
const isPausedRef = useRef(isPaused)
+1 -1
View File
@@ -16,7 +16,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "RouterLists",
title: "MikrotikManager",
description: "MikroTik network management platform",
};
+1
View File
@@ -4,3 +4,4 @@ dist/
*.db-shm
*.db-wal
.env
storage/backups/
+2 -1
View File
@@ -13,9 +13,10 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@mmapp/contracts": "1.0.0",
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
+126
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3"
import { existsSync, readFileSync } from "node:fs"
import path from "node:path"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
@@ -338,6 +340,72 @@ CREATE TABLE IF NOT EXISTS alert_telegram_settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS acme_settings (
id INTEGER PRIMARY KEY,
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 TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
step TEXT NOT NULL DEFAULT 'queued',
source TEXT NOT NULL DEFAULT 'manual',
server_id TEXT NOT NULL,
cert_name TEXT NOT NULL,
domain_names TEXT NOT NULL,
key_type TEXT NOT NULL DEFAULT 'rsa2048',
trust_store TEXT NOT NULL DEFAULT 'www,api',
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 21600,
renew_before_days INTEGER NOT NULL DEFAULT 30,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
frequency TEXT NOT NULL DEFAULT 'daily',
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',
server_ids_json TEXT NOT NULL DEFAULT '[]',
last_run_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backup_entries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'manual',
notes TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -587,12 +655,70 @@ SELECT 1, '', ''
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
`)
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
if (!certIssueJobCols.some((c) => c.name === "source")) {
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
}
sqlite.exec(`
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
SELECT 1, 1, 21600, 30
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
if (backupEntryCount.c === 0) {
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
if (existsSync(legacyIndexPath)) {
try {
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
if (Array.isArray(parsed)) {
const insert = sqlite.prepare(`
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
`)
for (const row of parsed) {
if (!row || typeof row !== "object") continue
const item = row as Record<string, unknown>
const id = String(item.id ?? "").trim()
const filename = String(item.filename ?? "").trim()
if (!id || !filename) continue
insert.run({
id,
serverId: String(item.serverId ?? ""),
serverName: String(item.serverName ?? ""),
filename,
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
kind: item.kind === "auto" ? "auto" : "manual",
notes: item.notes == null ? null : String(item.notes),
createdAt: String(item.createdAt ?? new Date().toISOString()),
})
}
}
} catch {
/* legacy index.json не читается — пропускаем */
}
}
}
export const db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
+69
View File
@@ -287,6 +287,70 @@ export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const acmeSettings = sqliteTable("acme_settings", {
id: integer("id").primaryKey(),
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
defaultZoneId: text("default_zone_id").notNull().default(""),
accountPrivateKey: text("account_private_key").notNull().default(""),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
id: text("id").primaryKey(),
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
step: text("step").notNull().default("queued"),
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
serverId: text("server_id").notNull(),
certName: text("cert_name").notNull(),
domainNames: text("domain_names").notNull(),
keyType: text("key_type").notNull().default("rsa2048"),
trustStore: text("trust_store").notNull().default("www,api"),
requestedAt: text("requested_at").notNull().default(sql`(datetime('now'))`),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
error: text("error"),
})
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(21600),
renewBeforeDays: integer("renew_before_days").notNull().default(30),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
hour: integer("hour").notNull().default(3),
minute: integer("minute").notNull().default(0),
weekDay: integer("week_day").notNull().default(0),
monthDay: integer("month_day").notNull().default(1),
keepCount: integer("keep_count").notNull().default(7),
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
serverIdsJson: text("server_ids_json").notNull().default("[]"),
lastRunAt: text("last_run_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const backupEntries = sqliteTable("backup_entries", {
id: text("id").primaryKey(),
serverId: text("server_id").notNull(),
serverName: text("server_name").notNull(),
filename: text("filename").notNull(),
sizeBytes: integer("size_bytes").notNull(),
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
notes: text("notes"),
createdAt: text("created_at").notNull(),
})
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(),
@@ -505,6 +569,11 @@ export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
export type EventRow = typeof events.$inferSelect
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
export type BackupEntryRow = typeof backupEntries.$inferSelect
export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
+2
View File
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
import alertsRoutes from "./routes/alerts.js"
import backupsRoutes from "./routes/backups.js"
import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
@@ -70,6 +71,7 @@ await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
+23
View File
@@ -0,0 +1,23 @@
export const PRODUCT_NAME = "MikrotikManager"
export const LEGACY_PRODUCT_NAME = "RouterLists"
export function hasManagedCommentPrefix(comment: string): boolean {
const value = comment.trim()
return value.startsWith(`${PRODUCT_NAME}:`) || value.startsWith(`${LEGACY_PRODUCT_NAME}:`)
}
export function hasManagedRecursiveComment(comment: string): boolean {
const value = comment.trim()
return (
value.startsWith(`${PRODUCT_NAME}:recursive`) ||
value.startsWith(`${LEGACY_PRODUCT_NAME}:recursive`)
)
}
export function managedComment(label: string): string {
return `${PRODUCT_NAME}: ${label}`
}
export function managedRecursiveComment(comment?: string | null): string {
return comment ? `${PRODUCT_NAME}:recursive ${comment}` : `${PRODUCT_NAME}:recursive`
}
+33 -87
View File
@@ -1,23 +1,22 @@
import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { appendEvent } from "../modules/events/service/events-service.js"
type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
notes?: string
}
import { refreshScheduler } from "../services/scheduler.js"
import {
deleteBackupRecord,
getBackupById,
getBackupsDir,
getBackupScheduleSettings,
listBackups,
runBackupForServer,
updateBackupScheduleSettings,
type BackupMeta,
} from "../services/backup-service.js"
type BackupJobStatus = "queued" | "running" | "done" | "failed"
type BackupJob = {
@@ -32,36 +31,8 @@ type BackupJob = {
failures: Array<{ serverId: string; error: string }>
}
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
const backupJobs = new Map<string, BackupJob>()
async function ensureStorage() {
await mkdir(BACKUPS_DIR, { recursive: true })
}
async function readIndex(): Promise<BackupMeta[]> {
await ensureStorage()
try {
const raw = await readFile(INDEX_PATH, "utf8")
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed as BackupMeta[]
} catch {
return []
}
}
async function writeIndex(rows: BackupMeta[]): Promise<void> {
await ensureStorage()
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
}
function fmtTs(d = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
const CreateBackupBodySchema = z.object({
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
notes: z.string().max(500).optional(),
@@ -74,43 +45,12 @@ const BackupJobIdParamSchema = z.object({
jobId: z.string().min(1),
})
async function runBackupForServer(id: string, notes?: string): Promise<BackupMeta> {
const serverIdNum = Number.parseInt(id, 10)
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
return {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
filename,
sizeBytes: st.size,
createdAt: new Date().toISOString(),
kind: "manual",
notes,
}
}
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.status = "running"
job.startedAt = new Date().toISOString()
const indexRows = await readIndex()
for (const id of ids) {
try {
const meta = await runBackupForServer(id, notes)
indexRows.unshift(meta)
const meta = await runBackupForServer(id, "manual", notes)
job.created.push(meta)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -119,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
job.completed += 1
}
}
await writeIndex(indexRows)
job.status = "done"
job.finishedAt = new Date().toISOString()
appendEvent({
@@ -141,9 +80,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/backups", async (_req, reply) => {
const rows = await readIndex()
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return reply.send(rows)
return reply.send(listBackups())
})
app.get("/backups/schedule", async (_req, reply) => {
return reply.send(getBackupScheduleSettings())
})
app.put("/backups/schedule", async (req, reply) => {
const parsed = putBackupScheduleSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateBackupScheduleSettings(parsed.data)
refreshScheduler()
return reply.send(result)
})
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
@@ -216,10 +167,9 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
const hit = rows.find((r) => r.id === req.params.id)
const hit = getBackupById(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
const filePath = path.join(BACKUPS_DIR, hit.filename)
const filePath = path.join(getBackupsDir(), hit.filename)
const content = await readFile(filePath, "utf8").catch(() => null)
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
reply.header("Content-Type", "text/plain; charset=utf-8")
@@ -228,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
})
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
const rows = await readIndex()
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
const [hit] = rows.splice(idx, 1)
await writeIndex(rows)
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
const hit = await deleteBackupRecord(req.params.id)
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
return reply.status(204).send()
})
}
+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from "node:crypto"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
certificateIssueRequestSchema,
putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema,
} from "@mmapp/contracts/certificates"
import { refreshScheduler } from "../services/scheduler.js"
import { testCloudflareToken } from "../services/acme-cloudflare.js"
import { queueCertificateIssueJob } from "../services/certificate-issue-runner.js"
import {
createIssueJobRecord,
getAcmeCloudflareToken,
getAcmeSettingsPublic,
getCertificateRenewSettings,
getIssueJobRecord,
getServerRowByIdString,
listCertificatesFromServers,
toIssueJobDto,
updateAcmeSettings,
updateCertificateRenewSettings,
} from "../services/certificates-service.js"
const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/certificates", async (_req, reply) => {
return reply.send(await listCertificatesFromServers())
})
app.post("/certificates/refresh", async (_req, reply) => {
return reply.send(await listCertificatesFromServers())
})
app.get("/certificates/acme-settings", async (_req, reply) => {
return reply.send(getAcmeSettingsPublic())
})
app.put("/certificates/acme-settings", async (req, reply) => {
const parsed = putAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
return reply.send(updateAcmeSettings(parsed.data))
})
app.get("/certificates/renew-settings", async (_req, reply) => {
return reply.send(getCertificateRenewSettings())
})
app.put("/certificates/renew-settings", async (req, reply) => {
const parsed = putCertificateRenewSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const result = updateCertificateRenewSettings(parsed.data)
refreshScheduler()
return reply.send(result)
})
app.post("/certificates/acme-settings/test", async (req, reply) => {
const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
if (!effective) {
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
}
try {
await testCloudflareToken(effective)
return reply.send({ ok: true, message: "Cloudflare API доступен" })
} catch (e) {
return reply.status(400).send({
ok: false,
message: e instanceof Error ? e.message : String(e),
})
}
})
app.post("/certificates/issue", async (req, reply) => {
const parsed = certificateIssueRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const serverId = String(parsed.data.serverId)
const server = getServerRowByIdString(serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const jobId = randomUUID()
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
createIssueJobRecord({
id: jobId,
serverId,
certName: parsed.data.certName.trim(),
domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean),
keyType: parsed.data.keyType ?? "rsa2048",
trustStore,
source: "manual",
})
queueCertificateIssueJob(jobId)
return reply.send({ jobId })
})
app.get("/certificates/issue/:jobId", async (req, reply) => {
const jobId = String((req.params as { jobId: string }).jobId)
const row = getIssueJobRecord(jobId)
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
return reply.send(toIssueJobDto(row))
})
}
export default certificatesRoutes
+9 -5
View File
@@ -5,6 +5,10 @@ import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import {
hasManagedCommentPrefix,
managedComment,
} from "../managed-markers.js"
type ServerRow = typeof servers.$inferSelect
@@ -595,11 +599,11 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
const isInBgpIn = (r: RosFilterRule) =>
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
const managedComment = `RouterLists: ${server.name || server.host}`
const managedCommentValue = managedComment(server.name || server.host)
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
const managedRule = existing.find(
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
)
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
@@ -608,7 +612,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
const conflictIds = existing
.filter(r =>
isInBgpIn(r) &&
!(r.comment ?? "").startsWith("RouterLists:") &&
!hasManagedCommentPrefix(r.comment ?? "") &&
/bgp-communities/i.test(r.rule ?? ""),
)
.map(r => r[".id"])
@@ -641,7 +645,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
{
chain: "bgp-in",
comment: managedComment,
comment: managedCommentValue,
rule: ruleBody,
disabled: "no",
},
@@ -650,7 +654,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
} else {
await client.post("/routing/filter/rule/add", {
chain: "bgp-in",
comment: managedComment,
comment: managedCommentValue,
rule: ruleBody,
})
app.log.info({ serverId: server.id }, "bgp-in rule created")
+6 -4
View File
@@ -4,6 +4,10 @@ import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
import {
hasManagedRecursiveComment,
managedRecursiveComment,
} from "../managed-markers.js"
type ServerRow = typeof servers.$inferSelect
@@ -108,9 +112,7 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
comment: route.comment
? `RouterLists:recursive ${route.comment}`
: "RouterLists:recursive",
comment: managedRecursiveComment(route.comment),
disabled: route.disabled ? "true" : "false",
}
}
@@ -235,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
try {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? ""))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
+11 -7
View File
@@ -1,4 +1,5 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { db } from "../db/index.js"
import {
filterRules,
@@ -16,13 +17,15 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeProbesTotal,
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
] = [
db.select().from(servers).all().length,
db.select().from(filterRules).all().length,
db.select().from(uptimeProbes).all().length,
db.select().from(uptimeSpeedProbes).all().length,
db.select().from(recursiveRoutes).all().length,
]
certificatesTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
Promise.resolve(db.select().from(uptimeProbes).all().length),
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
])
return reply.send({
servers: serversTotal,
@@ -31,6 +34,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeSpeedProbes: uptimeSpeedProbesTotal,
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
})
})
}
+353
View File
@@ -0,0 +1,353 @@
import { lookup } from "node:dns/promises"
import * as acme from "acme-client"
import type { Server } from "../db/schema.js"
import {
getAcmeAccountPrivateKey,
getAcmeCloudflareToken,
getAcmeSettingsPublic,
saveAcmeAccountPrivateKey,
} from "./certificates-service.js"
import { MikrotikClient } from "./mikrotik.js"
type CfResponse<T> = { success: boolean; errors?: Array<{ message?: string }>; result?: T }
type CfDnsRecord = {
id: string
type: string
name: string
content: string
}
type WanUplinkRow = {
iface?: string
ip?: string
}
async function cloudflareRequest<T>(
token: string,
path: string,
init?: RequestInit,
): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
...init,
headers,
})
const json = (await res.json()) as CfResponse<T>
if (!res.ok || !json.success) {
const msg = json.errors?.map((e) => e.message).filter(Boolean).join("; ")
|| `Cloudflare API HTTP ${res.status}`
throw new Error(msg)
}
return json.result as T
}
export async function testCloudflareToken(token: string): Promise<void> {
await cloudflareRequest<Array<{ id: string; name: string }>>(token, "/zones?per_page=1")
}
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
const labels = domain.split(".").filter(Boolean)
for (let i = 0; i < labels.length - 1; i++) {
const guess = labels.slice(i).join(".")
const zones = await cloudflareRequest<Array<{ id: string; name: string }>>(
token,
`/zones?name=${encodeURIComponent(guess)}`,
)
if (zones[0]?.id) return zones[0].id
}
if (defaultZoneId?.trim()) return defaultZoneId.trim()
throw new Error(`Не удалось определить зону Cloudflare для ${domain}`)
}
async function createTxtRecord(
token: string,
zoneId: string,
recordName: string,
value: string,
): Promise<string> {
const created = await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
method: "POST",
body: JSON.stringify({
type: "TXT",
name: recordName,
content: value,
ttl: 120,
}),
})
return created.id
}
async function deleteTxtRecord(token: string, zoneId: string, recordId: string): Promise<void> {
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
}
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] {
if (!raw?.trim()) return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
} catch {
return []
}
}
function normalizeIpv4(raw: string | undefined): string | null {
const value = raw?.trim()
if (!value) return null
const host = value.split("/")[0]?.trim() ?? ""
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return null
return host
}
function isPrivateIpv4(ip: string): boolean {
const [a, b] = ip.split(".").map((part) => Number.parseInt(part, 10))
if (a === 10) return true
if (a === 127) return true
if (a === 192 && b === 168) return true
if (a === 172 && b >= 16 && b <= 31) return true
return false
}
async function resolveHostIpv4(host: string): Promise<string | null> {
const literal = normalizeIpv4(host)
if (literal) return literal
const value = host.trim()
if (!value) return null
try {
const result = await lookup(value, { family: 4 })
return normalizeIpv4(result.address)
} catch {
return null
}
}
async function resolveServerPublicIp(server: Server, client?: MikrotikClient): Promise<string> {
const uplinks = parseWanUplinks(server.wanUplinks)
for (const uplink of uplinks) {
const configuredIp = normalizeIpv4(uplink.ip)
if (configuredIp) return configuredIp
}
const hostIp = await resolveHostIpv4(server.host)
if (hostIp) return hostIp
const rosClient = client ?? MikrotikClient.fromServer(server)
const addresses = await rosClient.getIpAddresses()
const ifaceNames = new Set(uplinks.map((u) => u.iface?.trim()).filter(Boolean))
const preferred = addresses.filter((row) => ifaceNames.has(String(row.interface ?? "").trim()))
const ordered = [...preferred, ...addresses]
for (const row of ordered) {
const ip = normalizeIpv4(String(row.address ?? ""))
if (ip && !isPrivateIpv4(ip)) return ip
}
for (const row of ordered) {
const ip = normalizeIpv4(String(row.address ?? ""))
if (ip) return ip
}
throw new Error("Не удалось определить IP сервера для A-записи Cloudflare")
}
async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string): Promise<CfDnsRecord[]> {
return cloudflareRequest<CfDnsRecord[]>(
token,
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(fqdn)}`,
)
}
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A")
if (existingA) {
if (existingA.content === ip) return
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH",
body: JSON.stringify({
type: "A",
name: fqdn,
content: ip,
ttl: 120,
proxied: false,
}),
})
return
}
if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
}
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
method: "POST",
body: JSON.stringify({
type: "A",
name: fqdn,
content: ip,
ttl: 120,
proxied: false,
}),
})
}
async function syncCertificateDomainRecords(
token: string,
domains: string[],
serverIp: string,
defaultZoneId?: string,
): Promise<void> {
for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp)
}
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms))
}
async function getOrCreateAccountKey(): Promise<Buffer> {
const existing = getAcmeAccountPrivateKey()
if (existing) return Buffer.from(existing)
const key = await acme.crypto.createPrivateKey()
saveAcmeAccountPrivateKey(key.toString("utf8"))
return key
}
async function ensureAcmeAccount(client: acme.Client): Promise<void> {
try {
client.getAccountUrl()
return
} catch {
await client.createAccount({ termsOfServiceAgreed: true })
}
}
function splitPemCertificates(pem: string): string[] {
return [...pem.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)]
.map((match) => match[0].trim())
.filter(Boolean)
}
export async function issueCertificateWithCloudflareDns(params: {
server: Server
certName: string
domainNames: string[]
keyType: "rsa2048" | "ec256"
trustStore: string[]
onStep?: (step: string) => void
}): Promise<void> {
const settings = getAcmeSettingsPublic()
const token = getAcmeCloudflareToken()
if (!token) throw new Error("Не настроен Cloudflare API token")
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
if (domains.length === 0) throw new Error("Нужен хотя бы один домен")
params.onStep?.("acme_order")
const accountKey = await getOrCreateAccountKey()
const client = new acme.Client({
directoryUrl: settings.directoryUrl,
accountKey,
})
await ensureAcmeAccount(client)
const altNames = domains.slice(1)
const privateKey = params.keyType === "ec256"
? await acme.crypto.createPrivateEcdsaKey("P-256")
: await acme.crypto.createPrivateKey(2048)
const [, csr] = await acme.crypto.createCsr({ commonName: domains[0], altNames }, privateKey)
const order = await client.createOrder({
identifiers: domains.map((value) => ({ type: "dns", value })),
})
const authorizations = await client.getAuthorizations(order)
const txtCleanups: Array<{ zoneId: string; recordId: string }> = []
try {
params.onStep?.("dns_challenge")
for (const authz of authorizations) {
const challenge = authz.challenges.find((c) => c.type === "dns-01")
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
const txtValue = await client.getChallengeKeyAuthorization(challenge)
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
const recordName = `_acme-challenge.${authz.identifier.value}`
const recordId = await createTxtRecord(token, zoneId, recordName, txtValue)
txtCleanups.push({ zoneId, recordId })
}
await sleep(15_000)
for (const authz of authorizations) {
const challenge = authz.challenges.find((c) => c.type === "dns-01")
if (!challenge) continue
await client.verifyChallenge(authz, challenge)
await client.completeChallenge(challenge)
await client.waitForValidStatus(authz)
}
params.onStep?.("finalize")
const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized)
const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos)
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
const trustStoreCsv = effectiveTrustStores.join(",")
params.onStep?.("import")
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
const chain = splitPemCertificates(certPem)
const leafPem = chain[0] ?? certPem
const chainRest = chain.slice(1)
const certFile = `${safeBase}.crt`
const keyFile = `${safeBase}.key`
const certRouterFile = await clientRos.uploadTextFile(certFile, leafPem)
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
await clientRos.importCertificate({
fileName: certRouterFile,
name: params.certName,
trusted: true,
trustStore: trustStoreCsv,
})
await clientRos.importCertificate({
fileName: keyRouterFile,
name: params.certName,
trusted: true,
trustStore: trustStoreCsv,
})
for (let index = 0; index < chainRest.length; index += 1) {
const isRoot = index === chainRest.length - 1
const caName = isRoot ? `root_${params.certName}` : `root_${params.certName}_${index + 1}`
const caFile = `${safeBase}_ca_${index + 1}.crt`
const caRouterFile = await clientRos.uploadTextFile(caFile, chainRest[index]!)
await clientRos.importCertificate({
fileName: caRouterFile,
name: caName,
trusted: true,
trustStore: trustStoreCsv,
})
}
await clientRos.applyCertificateToServices(params.certName)
} finally {
params.onStep?.("cleanup")
for (const item of txtCleanups) {
try {
await deleteTxtRecord(token, item.zoneId, item.recordId)
} catch {
/* ignore cleanup errors */
}
}
}
}
@@ -0,0 +1,136 @@
import { appendEvent } from "../modules/events/service/events-service.js"
import type { BackupsRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import {
getBackupScheduleSettings,
isBackupDue,
pruneBackupsForServer,
resolveBackupServerIds,
runBackupForServer,
touchBackupScheduleRunMeta,
} from "./backup-service.js"
let collecting = false
export function getBackupSchedulerCollectorState(): { running: boolean } {
return { running: collecting }
}
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getBackupScheduleSettings()
const snapshot: BackupsRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "backups",
sampledAt,
due: false,
created: 0,
failures: 0,
pruned: 0,
errors: [],
}
if (collecting) {
snapshot.skipped = true
return snapshot
}
if (!settings.enabled) {
snapshot.skipped = true
return snapshot
}
const due = isBackupDue(new Date(), settings, settings.lastRunAt)
snapshot.due = due
if (!due) {
return snapshot
}
if (settings.format !== "rsc") {
snapshot.skipped = true
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: 0,
lastError: snapshot.errors[0],
})
return snapshot
}
collecting = true
const started = Date.now()
const serverIds = resolveBackupServerIds(settings)
appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
title: "Запущен плановый бэкап",
message: `Серверов в очереди: ${serverIds.length}`,
entityType: "backup_job",
entityId: "scheduler",
payload: { serverIds, scheduled: true },
})
try {
for (const id of serverIds) {
try {
await runBackupForServer(id, "auto")
snapshot.created += 1
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.failures += 1
snapshot.errors?.push(`${id}: ${message}`)
}
}
for (const id of serverIds) {
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
}
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
})
appendEvent({
level: snapshot.failures > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
title: snapshot.failures > 0 ? "Плановый бэкап завершен с ошибками" : "Плановый бэкап завершен",
message: `Создано: ${snapshot.created}, ошибок: ${snapshot.failures}`,
entityType: "backup_job",
entityId: "scheduler",
payload: {
total: serverIds.length,
completed: snapshot.created + snapshot.failures,
failures: snapshot.errors,
pruned: snapshot.pruned,
},
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
snapshot.errors?.push(message)
touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
title: "Плановый бэкап прерван",
message,
entityType: "backup_job",
entityId: "scheduler",
})
return snapshot
} finally {
collecting = false
}
}
+273
View File
@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto"
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
export type BackupMeta = {
id: string
serverId: string
serverName: string
filename: string
sizeBytes: number
createdAt: string
kind: "manual" | "auto"
notes?: string
}
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
return {
id: row.id,
serverId: row.serverId,
serverName: row.serverName,
filename: row.filename,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt,
kind: row.kind,
notes: row.notes ?? undefined,
}
}
export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export function listBackups(): BackupMeta[] {
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
}
export function getBackupById(id: string): BackupMeta | null {
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
return row ? rowToMeta(row) : null
}
export function insertBackup(meta: BackupMeta): void {
db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
filename: meta.filename,
sizeBytes: meta.sizeBytes,
kind: meta.kind,
notes: meta.notes ?? null,
createdAt: meta.createdAt,
}).run()
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = getBackupById(id)
if (!hit) return null
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
}
function fmtTs(d = new Date()): string {
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
function parseServerIds(raw: string): string[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.map(String).filter(Boolean)
} catch {
return []
}
}
function getBackupScheduleSettingsRow() {
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
?? {
id: SETTINGS_ID,
enabled: true,
frequency: "daily" as const,
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc" as const,
serverIdsJson: "[]",
lastRunAt: null,
lastDurationMs: null,
lastError: null,
updatedAt: new Date().toISOString(),
}
}
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
const row = getBackupScheduleSettingsRow()
return {
enabled: row.enabled,
frequency: row.frequency,
hour: row.hour,
minute: row.minute,
weekDay: row.weekDay,
monthDay: row.monthDay,
keepCount: row.keepCount,
format: row.format,
serverIds: parseServerIds(row.serverIdsJson),
lastRunAt: row.lastRunAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
updatedAt: row.updatedAt,
}
}
export function updateBackupScheduleSettings(patch: Partial<{
enabled: boolean
frequency: "daily" | "weekly" | "monthly"
hour: number
minute: number
weekDay: number
monthDay: number
keepCount: number
format: "rsc" | "backup"
serverIds: string[]
}>) {
const prev = getBackupScheduleSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
frequency: patch.frequency ?? prev.frequency,
hour: patch.hour ?? prev.hour,
minute: patch.minute ?? prev.minute,
weekDay: patch.weekDay ?? prev.weekDay,
monthDay: patch.monthDay ?? prev.monthDay,
keepCount: patch.keepCount ?? prev.keepCount,
format: patch.format ?? prev.format,
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
updatedAt: now,
}
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
} else {
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
}
return getBackupScheduleSettings()
}
export function touchBackupScheduleRunMeta(patch: {
lastRunAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getBackupScheduleSettingsRow()
db.update(backupScheduleSettings).set({
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
}
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
const enabled = new Set(listServersRead().map((s) => String(s.id)))
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
return [...new Set(requested)].filter((id) => enabled.has(id))
}
function scheduledSlotForDate(now: Date, settings: BackupScheduleSettingsDto): Date | null {
if (settings.frequency === "weekly") {
const currentDow = (now.getDay() + 6) % 7
if (currentDow !== settings.weekDay) return null
} else if (settings.frequency === "monthly") {
if (now.getDate() !== settings.monthDay) return null
}
const slot = new Date(now)
slot.setSeconds(0, 0)
slot.setMilliseconds(0)
slot.setHours(settings.hour, settings.minute, 0, 0)
return slot
}
function sameLocalMinute(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate()
&& a.getHours() === b.getHours()
&& a.getMinutes() === b.getMinutes()
}
/** Срабатывает только в минуту расписания; 60 с в UI — интервал проверки, не частота бэкапа. */
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
if (!settings.enabled) return false
const slot = scheduledSlotForDate(now, settings)
if (!slot) return false
if (now < slot) return false
if (!sameLocalMinute(now, slot)) return false
if (lastRunAt) {
const prev = new Date(lastRunAt)
if (Number.isNaN(prev.getTime())) return true
if (sameLocalMinute(prev, slot)) return false
}
return true
}
export async function runBackupForServer(
id: string,
kind: BackupMeta["kind"],
notes?: string,
): Promise<BackupMeta> {
const serverIdNum = Number.parseInt(id, 10)
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
const client = MikrotikClient.fromServer(row)
const script = await client.exportConfigScript()
const ts = fmtTs()
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
const filename = `${safeServer}_${ts}.rsc`
const filePath = path.join(BACKUPS_DIR, filename)
await ensureBackupStorage()
await writeFile(filePath, script, "utf8")
const st = await stat(filePath)
const meta: BackupMeta = {
id: randomUUID(),
serverId: String(row.id),
serverName: row.name,
filename,
sizeBytes: st.size,
createdAt: new Date().toISOString(),
kind,
notes,
}
insertBackup(meta)
return meta
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = db.select().from(backupEntries)
.where(eq(backupEntries.serverId, serverId))
.orderBy(desc(backupEntries.createdAt))
.all()
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
}
return toDelete.length
}
export function getBackupsDir(): string {
return BACKUPS_DIR
}
@@ -0,0 +1,104 @@
import { appendEvent } from "../modules/events/service/events-service.js"
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
import {
getIssueJobRecord,
getServerRowByIdString,
updateIssueJobRecord,
} from "./certificates-service.js"
const runningJobs = new Set<string>()
export async function runCertificateIssueJob(jobId: string): Promise<void> {
if (runningJobs.has(jobId)) return
runningJobs.add(jobId)
const row = getIssueJobRecord(jobId)
if (!row) {
runningJobs.delete(jobId)
return
}
const server = getServerRowByIdString(row.serverId)
if (!server) {
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: "Сервер не найден",
})
runningJobs.delete(jobId)
return
}
let domainNames: string[] = []
try {
domainNames = JSON.parse(row.domainNames) as string[]
} catch {
domainNames = []
}
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
const startedAt = new Date().toISOString()
const isScheduler = row.source === "scheduler"
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
if (isScheduler) {
appendEvent({
level: "info",
eventType: "certificates.renew.started",
sourceModule: "certificates",
title: "Запущено автообновление сертификата",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
}
try {
await issueCertificateWithCloudflareDns({
server,
certName: row.certName,
domainNames,
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
onStep: (step) => updateIssueJobRecord(jobId, { step }),
})
updateIssueJobRecord(jobId, {
status: "done",
step: "done",
finishedAt: new Date().toISOString(),
error: null,
})
appendEvent({
level: "info",
eventType: isScheduler ? "certificates.renew.done" : "certificates.issue.done",
sourceModule: "certificates",
title: isScheduler ? "Сертификат обновлён" : "Сертификат выпущен",
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
entityType: "server",
entityId: String(server.id),
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: message,
})
appendEvent({
level: "warning",
eventType: isScheduler ? "certificates.renew.failed" : "certificates.issue.failed",
sourceModule: "certificates",
title: isScheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата",
message,
entityType: "server",
entityId: String(server.id),
})
} finally {
runningJobs.delete(jobId)
}
}
export function queueCertificateIssueJob(jobId: string): void {
queueMicrotask(() => { void runCertificateIssueJob(jobId) })
}
+112
View File
@@ -0,0 +1,112 @@
import type { CertificateDto } from "@mmapp/contracts/certificates"
export type RosCertificateRow = Record<string, string | undefined>
function parseRosDate(raw: string | undefined): Date | null {
if (!raw?.trim()) return null
const d = new Date(raw)
return Number.isNaN(d.getTime()) ? null : d
}
function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseSans(raw: string | undefined): string[] {
if (!raw?.trim()) return []
return raw
.split(",")
.map((part) => part.trim())
.map((part) => {
const m = part.match(/^(?:DNS|IP|email):(.+)$/i)
return (m?.[1] ?? part).trim()
})
.filter(Boolean)
}
function parseUsage(raw: string | undefined): CertificateDto["usage"] {
if (!raw?.trim()) return []
const out = new Set<CertificateDto["usage"][number]>()
for (const part of raw.split(",")) {
const token = part.trim().toLowerCase()
if (token === "tls-server") out.add("server")
else if (token === "tls-client") out.add("client")
else if (token === "key-cert-sign") out.add("ca")
else if (token === "crl-sign") out.add("crl-sign")
}
return [...out]
}
function parseFlags(raw: string | undefined): { revoked: boolean; expired: boolean } {
const flags = (raw ?? "").toUpperCase()
return {
revoked: flags.includes("R"),
expired: flags.includes("E"),
}
}
export function mapRosCertificateRow(
serverId: number,
serverName: string,
row: RosCertificateRow,
): CertificateDto | null {
const name = (row.name ?? "").trim()
if (!name) return null
const rosId = (row[".id"] ?? "").trim()
const fingerprint = (row.fingerprint ?? "").trim()
const serialNumber = (row["serial-number"] ?? "").trim()
const stableId = rosId || fingerprint || serialNumber || name
const { revoked, expired: expiredFlag } = parseFlags(row.flags)
const validFrom = parseRosDate(row["invalid-before"])
const validUntil = parseRosDate(row["invalid-after"])
const now = new Date()
const daysLeft = validUntil
? Math.ceil((validUntil.getTime() - now.getTime()) / 86_400_000)
: 0
let status: CertificateDto["status"] = "valid"
if (revoked) status = "revoked"
else if (expiredFlag || (validUntil != null && validUntil.getTime() < now.getTime())) status = "expired"
const keySize = Number.parseInt(String(row["key-size"] ?? "0"), 10)
return {
id: `${serverId}:${stableId}`,
name,
serverId: String(serverId),
serverName,
commonName: (row["common-name"] ?? name).trim(),
sans: parseSans(row["subject-alt-name"]),
issuedBy: (row.issuer ?? "—").trim() || "—",
validFrom: validFrom ? fmtDate(validFrom) : "—",
validUntil: validUntil ? fmtDate(validUntil) : "—",
daysLeft,
keySize: Number.isFinite(keySize) ? keySize : 0,
usage: parseUsage(row["key-usage"]),
trusted: row.trusted === "true",
status,
acmeStatus: row["acme-status"]?.trim() || undefined,
}
}
export function mapRosCertificates(
serverId: number,
serverName: string,
rows: RosCertificateRow[],
): CertificateDto[] {
const seen = new Set<string>()
return rows
.map((row) => mapRosCertificateRow(serverId, serverName, row))
.filter((row): row is CertificateDto => row != null)
.map((cert, index) => {
if (!seen.has(cert.id)) {
seen.add(cert.id)
return cert
}
const uniqueId = `${cert.id}#${index}`
seen.add(uniqueId)
return { ...cert, id: uniqueId }
})
}
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto"
import { appendEvent } from "../modules/events/service/events-service.js"
import type { CertificatesRenewRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { mapRosCertificates } from "./certificate-parse.js"
import { runCertificateIssueJob } from "./certificate-issue-runner.js"
import {
createIssueJobRecord,
getCertificateRenewSettings,
getServerRowByIdString,
hasActiveIssueJob,
listManagedCertificateTargets,
touchCertificateRenewRunMeta,
} from "./certificates-service.js"
import { MikrotikClient } from "./mikrotik.js"
let collecting = false
export function getCertificateRenewCollectorState(): { running: boolean } {
return { running: collecting }
}
export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getCertificateRenewSettings()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "certificates_renew",
sampledAt,
skipped: true,
checked: 0,
renewed: 0,
skippedTargets: 0,
errors: [],
}
}
collecting = true
const started = Date.now()
const snapshot: CertificatesRenewRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "certificates_renew",
sampledAt,
checked: 0,
renewed: 0,
skippedTargets: 0,
errors: [],
targets: [],
}
try {
if (!settings.enabled) {
snapshot.skipped = true
return snapshot
}
const targets = listManagedCertificateTargets()
for (const target of targets) {
snapshot.checked += 1
const item = {
serverId: target.serverId,
certName: target.certName,
action: "ok" as "ok" | "renewed" | "skipped" | "error",
daysLeft: null as number | null,
message: "",
}
if (hasActiveIssueJob(target.serverId, target.certName)) {
item.action = "skipped"
item.message = "Уже выполняется выпуск"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
appendEvent({
level: "info",
eventType: "certificates.renew.skipped",
sourceModule: "certificates",
title: "Автообновление пропущено",
message: `${target.certName}: уже выполняется выпуск`,
entityType: "server",
entityId: target.serverId,
})
continue
}
const server = getServerRowByIdString(target.serverId)
if (!server) {
item.action = "error"
item.message = "Сервер не найден"
snapshot.errors.push(`${target.certName}: сервер не найден`)
snapshot.targets?.push(item)
continue
}
try {
const client = MikrotikClient.fromServer(server)
const rows = await client.getCertificates()
const mapped = mapRosCertificates(server.id, server.name, rows)
const hit = mapped.find((c) => c.name === target.certName)
if (!hit) {
item.action = "error"
item.message = "Сертификат не найден на устройстве"
snapshot.errors.push(`${target.certName}: не найден на ${server.name}`)
snapshot.targets?.push(item)
continue
}
item.daysLeft = hit.daysLeft
if (hit.daysLeft > settings.renewBeforeDays) {
item.action = "ok"
item.message = `До истечения ${hit.daysLeft} дн.`
snapshot.targets?.push(item)
continue
}
const jobId = randomUUID()
createIssueJobRecord({
id: jobId,
serverId: target.serverId,
certName: target.certName,
domainNames: target.domainNames,
keyType: target.keyType,
trustStore: target.trustStore,
source: "scheduler",
})
await runCertificateIssueJob(jobId)
snapshot.renewed += 1
item.action = "renewed"
item.message = "Запущено обновление"
snapshot.targets?.push(item)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
item.action = "error"
item.message = message
snapshot.errors.push(`${target.certName}: ${message}`)
snapshot.targets?.push(item)
}
}
touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors.length > 0 ? snapshot.errors.join("; ") : null,
})
return snapshot
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
return snapshot
} finally {
collecting = false
}
}
@@ -0,0 +1,286 @@
import { desc, eq, inArray } from "drizzle-orm"
import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { db } from "../db/index.js"
import {
acmeSettings,
certificateIssueJobs,
certificateRenewSettings,
servers,
} from "../db/schema.js"
import { mapRosCertificates } from "./certificate-parse.js"
import { MikrotikClient } from "./mikrotik.js"
const SETTINGS_ID = 1
export type ManagedCertificateTarget = {
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
finishedAt: string
}
export async function listCertificatesFromServers(): Promise<{
certificates: CertificateDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const certificates: CertificateDto[] = []
const failures: Array<{ serverId: string; serverName?: string; error: string }> = []
await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const rows = await client.getCertificates()
certificates.push(...mapRosCertificates(server.id, server.name, rows))
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name,
error: e instanceof Error ? e.message : String(e),
})
}
}),
)
certificates.sort((a, b) => {
if (a.serverId !== b.serverId) return a.serverId.localeCompare(b.serverId)
return a.name.localeCompare(b.name, "ru")
})
return { certificates, failures }
}
export function getAcmeSettingsPublic() {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
if (!row) {
return {
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
defaultZoneId: "",
tokenConfigured: false,
updatedAt: undefined as string | undefined,
}
}
return {
directoryUrl: row.directoryUrl,
defaultZoneId: row.defaultZoneId || undefined,
tokenConfigured: Boolean(row.cloudflareApiToken?.trim()),
updatedAt: row.updatedAt,
}
}
export function getAcmeCloudflareToken(): string {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
return row?.cloudflareApiToken?.trim() ?? ""
}
export function getAcmeAccountPrivateKey(): string {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
return row?.accountPrivateKey?.trim() ?? ""
}
export function saveAcmeAccountPrivateKey(pem: string) {
const now = new Date().toISOString()
db.update(acmeSettings)
.set({ accountPrivateKey: pem, updatedAt: now })
.where(eq(acmeSettings.id, SETTINGS_ID))
.run()
}
export function updateAcmeSettings(input: {
directoryUrl?: string
defaultZoneId?: string | null
cloudflareApiToken?: string | null
}) {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
const now = new Date().toISOString()
const next = {
directoryUrl: input.directoryUrl?.trim() || row?.directoryUrl || "https://acme-v02.api.letsencrypt.org/directory",
defaultZoneId:
input.defaultZoneId === null
? ""
: input.defaultZoneId?.trim() ?? row?.defaultZoneId ?? "",
cloudflareApiToken:
input.cloudflareApiToken === null
? ""
: input.cloudflareApiToken?.trim() ?? row?.cloudflareApiToken ?? "",
updatedAt: now,
}
if (row) {
db.update(acmeSettings).set(next).where(eq(acmeSettings.id, SETTINGS_ID)).run()
} else {
db.insert(acmeSettings).values({ id: SETTINGS_ID, accountPrivateKey: "", ...next }).run()
}
return getAcmeSettingsPublic()
}
export function createIssueJobRecord(input: {
id: string
serverId: string
certName: string
domainNames: string[]
keyType: string
trustStore: string
source?: "manual" | "scheduler"
}) {
const now = new Date().toISOString()
db.insert(certificateIssueJobs).values({
id: input.id,
status: "queued",
step: "queued",
source: input.source ?? "manual",
serverId: input.serverId,
certName: input.certName,
domainNames: JSON.stringify(input.domainNames),
keyType: input.keyType,
trustStore: input.trustStore,
requestedAt: now,
}).run()
}
export function updateIssueJobRecord(
id: string,
patch: Partial<{
status: "queued" | "running" | "done" | "failed"
step: string
startedAt: string
finishedAt: string
error: string | null
}>,
) {
db.update(certificateIssueJobs).set(patch).where(eq(certificateIssueJobs.id, id)).run()
}
export function getIssueJobRecord(id: string) {
return db.select().from(certificateIssueJobs).where(eq(certificateIssueJobs.id, id)).limit(1).all()[0] ?? null
}
export function toIssueJobDto(row: NonNullable<ReturnType<typeof getIssueJobRecord>>) {
let domainNames: string[] = []
try {
const parsed = JSON.parse(row.domainNames) as unknown
if (Array.isArray(parsed)) domainNames = parsed.map(String)
} catch {
domainNames = []
}
return {
id: row.id,
status: row.status,
step: row.step as "queued" | "acme_order" | "dns_challenge" | "finalize" | "import" | "cleanup" | "done",
serverId: row.serverId,
certName: row.certName,
domainNames,
requestedAt: row.requestedAt,
startedAt: row.startedAt ?? undefined,
finishedAt: row.finishedAt ?? undefined,
error: row.error ?? undefined,
}
}
export function getServerRowByIdString(serverId: string) {
const id = Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
}
function getCertificateRenewSettingsRow() {
return db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]
?? {
id: SETTINGS_ID,
enabled: true,
intervalSec: 21600,
renewBeforeDays: 30,
lastCollectedAt: null,
lastDurationMs: null,
lastError: null,
updatedAt: new Date().toISOString(),
}
}
export function getCertificateRenewSettings(): CertificateRenewSettingsDto {
const row = getCertificateRenewSettingsRow()
return {
enabled: row.enabled,
intervalSec: row.intervalSec,
renewBeforeDays: row.renewBeforeDays,
lastCollectedAt: row.lastCollectedAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError ?? null,
updatedAt: row.updatedAt,
}
}
export function updateCertificateRenewSettings(patch: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
}) {
const prev = getCertificateRenewSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
renewBeforeDays: patch.renewBeforeDays ?? prev.renewBeforeDays,
updatedAt: now,
}
if (db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
} else {
db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next }).run()
}
return getCertificateRenewSettings()
}
export function touchCertificateRenewRunMeta(patch: {
lastCollectedAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getCertificateRenewSettingsRow()
db.update(certificateRenewSettings).set({
lastCollectedAt: patch.lastCollectedAt ?? prev.lastCollectedAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
}
export function listManagedCertificateTargets(): ManagedCertificateTarget[] {
const rows = db.select().from(certificateIssueJobs)
.where(eq(certificateIssueJobs.status, "done"))
.orderBy(desc(certificateIssueJobs.finishedAt), desc(certificateIssueJobs.requestedAt))
.all()
const byKey = new Map<string, ManagedCertificateTarget>()
for (const row of rows) {
const key = `${row.serverId}::${row.certName}`
if (byKey.has(key)) continue
let domainNames: string[] = []
try {
const parsed = JSON.parse(row.domainNames) as unknown
if (Array.isArray(parsed)) domainNames = parsed.map(String)
} catch {
domainNames = []
}
byKey.set(key, {
serverId: row.serverId,
certName: row.certName,
domainNames,
keyType: row.keyType,
trustStore: row.trustStore,
finishedAt: row.finishedAt ?? row.requestedAt,
})
}
return [...byKey.values()]
}
export function hasActiveIssueJob(serverId: string, certName: string): boolean {
const row = db.select().from(certificateIssueJobs)
.where(inArray(certificateIssueJobs.status, ["queued", "running"]))
.orderBy(desc(certificateIssueJobs.requestedAt))
.all()
.find((r) => r.serverId === serverId && r.certName === certName)
return Boolean(row)
}
+164
View File
@@ -156,6 +156,62 @@ function rosPost(
})
}
function rosPut(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "PUT",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf))
return
}
try {
resolve(buf.trim() ? JSON.parse(buf) : {})
} catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.write(payload)
req.end()
})
}
function rosDelete(
params: MikrotikConnectParams,
path: string,
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
throw new Error("RouterOS REST: неожиданное тело ответа")
}
function routerFileBasename(fileName: string): string {
return fileName.replace(/^\/+/, "").split("/").pop() ?? fileName
}
function matchesUploadedFile(entryName: string, requested: string): boolean {
const base = routerFileBasename(requested)
return entryName === requested
|| entryName === base
|| entryName === `flash/${base}`
|| entryName.endsWith(`/${base}`)
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -295,6 +363,10 @@ export class MikrotikClient {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
async put<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPut(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
@@ -431,6 +503,98 @@ export class MikrotikClient {
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
}
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {
const raw = await this.get<unknown>("/certificate")
if (!Array.isArray(raw)) return []
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
}
async listFiles(): Promise<Array<{ name: string }>> {
const raw = await this.get<unknown>("/file")
if (!Array.isArray(raw)) return []
return raw
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
.map((row) => ({ name: String(row.name ?? "") }))
.filter((row) => row.name.length > 0)
}
private async resolveUploadedFileName(requested: string): Promise<string> {
const files = await this.listFiles()
const match = files.find((file) => matchesUploadedFile(file.name, requested))
if (!match) {
throw new Error(`Файл ${routerFileBasename(requested)} не найден на RouterOS после загрузки`)
}
return match.name
}
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<string> {
const normalized = routerFileBasename(fileName)
const payload = contents.endsWith("\n") ? contents : `${contents}\n`
const candidates = [`flash/${normalized}`, normalized]
let lastError: unknown
for (const name of candidates) {
try {
await this.put("/file", { name, contents: payload }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
try {
await this.post("/file/add", { name, contents: payload, type: "file" }, timeoutMs)
return await this.resolveUploadedFileName(name)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
}
async importCertificate(params: {
fileName: string
name: string
trusted?: boolean
trustStore?: string
passphrase?: string
}): Promise<unknown> {
const body: Record<string, string> = {
"file-name": params.fileName,
name: params.name,
trusted: params.trusted === false ? "no" : "yes",
"trust-store": params.trustStore?.trim() || "www,api",
}
if (params.passphrase?.trim()) body.passphrase = params.passphrase.trim()
return this.post("/certificate/import", body, 60_000)
}
async applyCertificateToServices(certName: string): Promise<void> {
const body = {
disabled: "no",
certificate: certName,
}
for (const serviceName of ["www-ssl", "api-ssl"]) {
await this.patchIpService(serviceName, body)
}
}
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
try {
await this.patch(pathByName, body, 30_000)
return
} catch {
const services = await this.get<Array<Record<string, string | undefined>>>("/ip/service")
const row = services.find((service) => String(service.name ?? "") === serviceName)
const id = row?.[".id"]
if (!id) throw new Error(`Сервис RouterOS ${serviceName} не найден`)
await this.patch(`/ip/service/${encodeURIComponent(id)}`, body, 30_000)
}
}
async exportConfigScript(): Promise<string> {
const raw = await this.post<unknown>("/console/export", {}, 30_000)
+40
View File
@@ -42,6 +42,10 @@ import {
collectInternetPathSnapshotOnce,
getInternetPathSettings,
} from "./internet-path-collector.js"
import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
import { getCertificateRenewSettings } from "./certificates-service.js"
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
"uptime_speed",
"internet_path",
"gre_bgp",
"certificates_renew",
"backups",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
case "internet_path":
snapshot = await collectInternetPathSnapshotOnce()
break
case "certificates_renew":
snapshot = await collectCertificatesRenewOnce()
break
case "backups":
snapshot = await collectScheduledBackupsOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
}, greBgpMs),
)
const certRenew = getCertificateRenewSettings()
if (certRenew.enabled) {
const certRenewMs = Math.max(300_000, certRenew.intervalSec * 1000)
void executeSchedulerJob("certificates_renew").catch(() => {})
timers.set(
"certificates_renew",
setInterval(() => {
void executeSchedulerJob("certificates_renew").catch(() => {})
}, certRenewMs),
)
}
const backupSchedule = getBackupScheduleSettings()
if (backupSchedule.enabled) {
const backupMs = 60_000
void executeSchedulerJob("backups").catch(() => {})
timers.set(
"backups",
setInterval(() => {
void executeSchedulerJob("backups").catch(() => {})
}, backupMs),
)
}
const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {})
timers.set(
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
const certRenew = getCertificateRenewSettings()
const backupSchedule = getBackupScheduleSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
gre_bgp: { enabled: true, intervalSec: 30 },
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
alert_engine: { enabled: true, intervalSec: 20 },
}
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
fatalError?: string
}
export interface CertificatesRenewTargetSnapshot {
serverId: string
certName: string
action: "ok" | "renewed" | "skipped" | "error"
daysLeft: number | null
message: string
}
export interface CertificatesRenewRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "certificates_renew"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
renewed: number
skippedTargets: number
errors: string[]
targets?: CertificatesRenewTargetSnapshot[]
}
export interface BackupsRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "backups"
sampledAt: string
skipped?: boolean
due: boolean
created: number
failures: number
pruned: number
errors?: string[]
fatalError?: string
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot
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
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-111
View File
@@ -1,111 +0,0 @@
[
{
"id": "b820bdc8-d184-4615-92e1-40df3872d554",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-41-51.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:41:51.371Z",
"kind": "manual"
},
{
"id": "7d46ee5b-c829-4d42-af90-847026bafd8a",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-19-22.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:19:22.073Z",
"kind": "manual"
},
{
"id": "c34f71e5-6766-456b-9b8a-9b8efaa91edc",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-19-19.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:19:19.639Z",
"kind": "manual"
},
{
"id": "40e253a1-98a8-40a7-982b-7c5af213f490",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:19:17.801Z",
"kind": "manual"
},
{
"id": "9199d0a8-9bc0-4d20-8498-b37f1f15262b",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-19-17.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:19:17.272Z",
"kind": "manual"
},
{
"id": "4de5263f-edb4-4ca9-ae90-2247defdc05e",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-19-14.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:19:14.772Z",
"kind": "manual"
},
{
"id": "de49d319-a24f-475c-bb6e-8075eff86380",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-16-45.rsc",
"sizeBytes": 911521,
"createdAt": "2026-05-07T07:16:45.543Z",
"kind": "manual",
"notes": "async"
},
{
"id": "ec5b9e31-42cb-40b9-aba7-3501d5145713",
"serverId": "6",
"serverName": "ihor.msk.rt.shx.su",
"filename": "ihor.msk.rt.shx.su_2026-05-07_14-13-58.rsc",
"sizeBytes": 676415,
"createdAt": "2026-05-07T07:13:58.749Z",
"kind": "manual"
},
{
"id": "7909c7de-a548-44d4-bdf7-7c221bfedd36",
"serverId": "5",
"serverName": "servhost.nsk.rt.shx.su",
"filename": "servhost.nsk.rt.shx.su_2026-05-07_14-13-56.rsc",
"sizeBytes": 672268,
"createdAt": "2026-05-07T07:13:56.245Z",
"kind": "manual"
},
{
"id": "63339ebb-eb94-455e-a61b-368523fed7e1",
"serverId": "4",
"serverName": "veesp.swe.rt.shx.su",
"filename": "veesp.swe.rt.shx.su_2026-05-07_14-13-54.rsc",
"sizeBytes": 2131,
"createdAt": "2026-05-07T07:13:54.493Z",
"kind": "manual"
},
{
"id": "daccab1d-f60a-4570-9d11-c7b06491f6f7",
"serverId": "3",
"serverName": "vpsville.msk.rt.shx.su",
"filename": "vpsville.msk.rt.shx.su_2026-05-07_14-13-53.rsc",
"sizeBytes": 675732,
"createdAt": "2026-05-07T07:13:53.791Z",
"kind": "manual"
},
{
"id": "d976cae6-aae8-4f55-9452-71d5480ac8e8",
"serverId": "2",
"serverName": "Gateway",
"filename": "Gateway_2026-05-07_14-13-48.rsc",
"sizeBytes": 913056,
"createdAt": "2026-05-07T07:13:48.326Z",
"kind": "manual"
}
]
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
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:21.880Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:13:54.118Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
@@ -1,44 +0,0 @@
# synthetic export generated by MikrotikManager
# generated-at: 2026-05-07T07:19:17.424Z
/system identity
set name="veesp.swe.rt.shx.su"
/interface
:put "interface name="ether1" mtu=1500 disabled=no"
:put "interface name="MSK-DC" mtu=1350 disabled=no"
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
:put "interface name="br1" mtu=1500 disabled=no"
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
:put "interface name="lo" mtu=65536 disabled=no"
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
:put "interface name="wg1" mtu=1280 disabled=no"
/ip address
add address=62.182.194.146/24 interface="ether1"
add address=10.200.100.26/30 interface="MSK-IHOR"
add address=10.200.200.2/30 interface="*5"
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
add address=10.205.1.2/30 interface="wg1"
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
add address=10.101.1.2/30 interface="gre-tunnel1"
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
/ip route
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
add dst-address=62.182.194.0/24 gateway=br1 distance=0
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
/ip firewall filter
add chain=input action=drop protocol=udp dst-port=53
add chain=input action=accept protocol=udp dst-port=13231
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
+9 -9
View File
@@ -124,11 +124,11 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { resolvedTheme, setTheme } = useTheme()
const { mode, backendUrl, backendStatus } = useDataSource()
const { mode, backendUrl } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -142,7 +142,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [])
React.useEffect(() => {
if (mode !== "live" || backendStatus !== true) {
if (mode !== "live") {
setLiveCounts(null)
return
}
@@ -176,15 +176,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendStatus, backendUrl])
}, [mode, backendUrl])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
if (mode !== "live") return mockBadges[url]
if (backendStatus !== true) return undefined
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
if (!evo.enabled) return mockBadges[url]
if (!evo.enabled) return undefined
if (!evo.snapshot) return undefined
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
@@ -197,8 +196,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
return undefined
}
@@ -214,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
badge: mounted ? badgeFor(it.url) : undefined,
})),
}))
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
const appVersionLabel = formatAppVersionLabel(getAppVersion())
@@ -227,7 +227,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<RouterIcon className="size-4" />
</div>
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
</div>
</div>
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
import {
formatCommitSubject,
getReleaseCommitPreview,
RELEASE_COMMIT_PREVIEW_LIMIT,
} from "@/lib/release-commits"
import type { ReleaseManifest } from "@/lib/release-manifest"
import { hasSeenReleaseModal, markReleaseModalSeen } from "@/lib/release-modal-storage"
export function ReleaseNotesModal() {
const [open, setOpen] = React.useState(false)
const [manifest, setManifest] = React.useState<ReleaseManifest | null>(null)
React.useEffect(() => {
let cancelled = false
async function loadManifest() {
try {
const response = await fetch("/release-manifest.json", { cache: "no-store" })
if (!response.ok) return
const data = (await response.json()) as ReleaseManifest
if (cancelled || !data.commits?.length || hasSeenReleaseModal(data.tag)) return
setManifest(data)
setOpen(true)
} catch {
// Локальная сборка или недоступный manifest — модалку не показываем.
}
}
void loadManifest()
return () => {
cancelled = true
}
}, [])
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen && manifest) {
markReleaseModalSeen(manifest.tag)
}
}
const versionLabel = formatAppVersionLabel(manifest?.version ?? getAppVersion())
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[min(85vh,720px)] w-full max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg">
<DialogHeader className="gap-1.5 border-b px-6 py-5 pr-12 text-left">
<DialogTitle className="text-base leading-snug">
Что нового в {versionLabel}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
Последние {RELEASE_COMMIT_PREVIEW_LIMIT} коммитов текущей сборки MikrotikManager.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{commits.length ? (
<ul className="flex flex-col gap-2">
{commits.map((commit) => (
<li
key={commit.sha}
className="rounded-lg border bg-card/80 p-3"
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge variant="secondary" className="font-mono uppercase">
{commit.type}
</Badge>
<span className="font-mono text-xs text-muted-foreground">{commit.shortSha}</span>
<span className="text-xs text-muted-foreground">{commit.author}</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-foreground">
{formatCommitSubject(commit.subject)}
</p>
</li>
))}
</ul>
) : null}
</div>
<DialogFooter className="mx-0 mb-0 shrink-0 gap-3 border-t bg-muted/30 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<Button
variant="outline"
render={<Link href="/releases" />}
onClick={() => handleOpenChange(false)}
>
Все релизы
</Button>
<Button onClick={() => handleOpenChange(false)}>Понятно</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+4
View File
@@ -18,8 +18,12 @@ services:
image: git.shts.su/denozord/mikrotikmanager-frontend:latest
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "3000:3000"
environment:
BACKEND_INTERNAL_URL: http://backend:8000
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: frontend
+23 -6
View File
@@ -80,16 +80,32 @@ state_set_error() {
mv "$tmp" "$STATE_FILE"
}
cleanup_legacy_lock_dirs() {
local locks_dir="/state/locks"
[[ -d "$locks_dir" ]] || return 0
find "$locks_dir" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null || true
}
with_target_lock() {
local target_id="$1"
shift
local lock_dir="/state/locks/${target_id}"
mkdir -p "$(dirname "$lock_dir")"
if ! mkdir "$lock_dir" 2>/dev/null; then
log warn "target=${target_id} action=skip reason=lock_busy"
return 0
local lock_file="/state/locks/${target_id}.lock"
mkdir -p "$(dirname "$lock_file")"
if [[ -f "$lock_file" ]]; then
local lock_pid=""
lock_pid="$(tr -d '[:space:]' <"$lock_file" 2>/dev/null || true)"
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
log warn "target=${target_id} action=skip reason=lock_busy pid=${lock_pid}"
return 0
fi
log warn "target=${target_id} action=stale_lock_removed previous_pid=${lock_pid:-unknown}"
rm -f "$lock_file"
fi
trap 'rmdir "'"$lock_dir"'" 2>/dev/null || true' RETURN
printf '%s\n' "$$" >"$lock_file"
trap 'rm -f "'"$lock_file"'"' RETURN
"$@"
}
@@ -483,6 +499,7 @@ main() {
validate_targets_file
state_init
cleanup_legacy_lock_dirs
registry_login
log info "action=startup targets_file=${TARGETS_FILE} state_file=${STATE_FILE} poll_interval=${POLL_INTERVAL_SECONDS}"
+25 -7
View File
@@ -14,6 +14,11 @@ import {
export type DataSourceMode = "mock" | "live"
/** Режим «Живые» в настройках: моковые данные не подставлять. */
export function isLiveDataSourceMode(mode: DataSourceMode): boolean {
return mode === "live"
}
interface DataSourceContextValue {
mode: DataSourceMode
setMode: (m: DataSourceMode) => void
@@ -21,6 +26,8 @@ interface DataSourceContextValue {
setBackendUrl: (url: string) => void
backendUrlLocked: boolean
mockModeAvailable: boolean
/** localStorage для mode/backendUrl применён на клиенте (после первого paint). */
prefsHydrated: boolean
/** undefined = не проверялось, true = OK, false = недоступен */
backendStatus: boolean | undefined
checkBackend: () => Promise<void>
@@ -58,13 +65,20 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
const [backendUrl, setBackendUrlState] = useState(() =>
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
)
const [prefsHydrated, setPrefsHydrated] = useState(() => typeof window !== "undefined")
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable()
useEffect(() => {
if (configuredBackendUrl().kind !== "same-origin") return
setBackendUrlState(window.location.origin)
const storedMode = readStoredMode()
let url = readStoredBackendUrl()
if (configuredBackendUrl().kind === "same-origin") {
url = window.location.origin
}
setModeState(storedMode)
setBackendUrlState(url)
setPrefsHydrated(true)
}, [])
const setMode = useCallback((m: DataSourceMode) => {
@@ -81,9 +95,12 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const url = normalizeBackendUrl(backendUrl)
const healthUrl =
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
} catch {
setBackendStatus(false)
@@ -91,12 +108,13 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrl])
useEffect(() => {
if (mode === "live") queueMicrotask(() => { void checkBackend() })
}, [mode, checkBackend])
if (!prefsHydrated || mode !== "live") return
queueMicrotask(() => { void checkBackend() })
}, [prefsHydrated, mode, checkBackend])
return (
<DataSourceContext.Provider
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend }}
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, prefsHydrated, backendStatus, checkBackend }}
>
{children}
</DataSourceContext.Provider>
+7 -7
View File
@@ -758,15 +758,15 @@ export const routerCertificates: RouterCertificate[] = [
id: "cert1", name: "mt-msk-core-01-server", serverId: "srv1",
commonName: "mt-msk-core-01.routerlists.io",
sans: ["mt-msk-core-01.routerlists.io","10.0.0.1"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
{
id: "cert2", name: "routerlists-internal-ca", serverId: "srv1",
commonName: "RouterLists Internal CA",
commonName: "MikrotikManager Internal CA",
sans: [],
issuedBy: "RouterLists Internal CA (self-signed)", validFrom: "2023-06-01",
issuedBy: "MikrotikManager Internal CA (self-signed)", validFrom: "2023-06-01",
validUntil: "2033-06-01", daysLeft: 2589,
keySize: 4096, usage: ["ca","crl-sign"], trusted: true, status: "valid",
},
@@ -774,7 +774,7 @@ export const routerCertificates: RouterCertificate[] = [
id: "cert3", name: "mt-spb-edge-01-server", serverId: "srv2",
commonName: "mt-spb-edge-01.routerlists.io",
sans: ["mt-spb-edge-01.routerlists.io","10.0.1.1"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
@@ -790,7 +790,7 @@ export const routerCertificates: RouterCertificate[] = [
id: "cert5", name: "wg-client-admin", serverId: "srv1",
commonName: "admin.routerlists.io",
sans: ["admin.routerlists.io"],
issuedBy: "RouterLists Internal CA", validFrom: "2025-06-01",
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-06-01",
validUntil: "2026-06-01", daysLeft: 33,
keySize: 2048, usage: ["client"], trusted: true, status: "valid",
},
@@ -798,7 +798,7 @@ export const routerCertificates: RouterCertificate[] = [
id: "cert6", name: "mt-ams-edge-01-server", serverId: "srv4",
commonName: "mt-ams-edge-01.routerlists.io",
sans: ["mt-ams-edge-01.routerlists.io"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-04-01",
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-04-01",
validUntil: "2026-05-15", daysLeft: 16,
keySize: 2048, usage: ["server"], trusted: false, status: "valid",
},
@@ -806,7 +806,7 @@ export const routerCertificates: RouterCertificate[] = [
id: "cert7", name: "mt-msk-lab-01-server", serverId: "srv7",
commonName: "mt-msk-lab-01.routerlists.io",
sans: ["mt-msk-lab-01.routerlists.io","10.0.0.7"],
issuedBy: "RouterLists Internal CA", validFrom: "2025-09-01",
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-09-01",
validUntil: "2026-09-01", daysLeft: 125,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
+12
View File
@@ -0,0 +1,12 @@
export const RELEASE_COMMIT_PREVIEW_LIMIT = 6
export function formatCommitSubject(subject: string): string {
return subject.replace(
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*/i,
"",
)
}
export function getReleaseCommitPreview<T>(commits: T[], limit = RELEASE_COMMIT_PREVIEW_LIMIT): T[] {
return commits.slice(0, limit)
}
+14
View File
@@ -0,0 +1,14 @@
const STORAGE_PREFIX = "routerlists:release-modal:"
export function getReleaseModalStorageKey(tag: string): string {
return `${STORAGE_PREFIX}${tag}`
}
export function hasSeenReleaseModal(tag: string): boolean {
if (typeof window === "undefined") return true
return window.localStorage.getItem(getReleaseModalStorageKey(tag)) === "1"
}
export function markReleaseModalSeen(tag: string): void {
window.localStorage.setItem(getReleaseModalStorageKey(tag), "1")
}
+27
View File
@@ -72,6 +72,31 @@ export interface InternetPathRunSnapshot {
fatalError?: string
}
export interface CertificatesRenewRunSnapshot {
v: number
job: "certificates_renew"
sampledAt: string
skipped?: boolean
fatalError?: string
checked: number
renewed: number
skippedTargets: number
errors: string[]
}
export interface BackupsRunSnapshot {
v: number
job: "backups"
sampledAt: string
skipped?: boolean
due: boolean
created: number
failures: number
pruned: number
errors?: string[]
fatalError?: string
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
@@ -80,6 +105,8 @@ export type SchedulerRunSnapshot =
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| InternetPathRunSnapshot
| CertificatesRenewRunSnapshot
| BackupsRunSnapshot
| AlertEngineRunSnapshot
export interface TrafficServerSnapshot {
+8
View File
@@ -9,6 +9,8 @@ export const SCHEDULER_JOB_KEYS = [
"uptime_speed",
"internet_path",
"gre_bgp",
"certificates_renew",
"backups",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
@@ -21,6 +23,8 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
uptime_speed: "Uptime: speed",
internet_path: "Internet Path",
gre_bgp: "GRE + BGP",
certificates_renew: "Сертификаты: автообновление",
backups: "Бэкапы",
alert_engine: "Оповещения",
}
@@ -35,6 +39,10 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).",
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
alert_engine:
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
}
+343 -2
View File
@@ -44,6 +44,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
@@ -3075,6 +3076,163 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -4136,6 +4294,22 @@
"node": ">= 0.6"
}
},
"node_modules/acme-client": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.7.2",
"debug": "^4.3.5",
"node-forge": "^1.3.1"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -4427,6 +4601,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -4456,6 +4644,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -4511,6 +4705,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -4977,6 +5182,18 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -5341,6 +5558,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5774,7 +6000,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6807,6 +7032,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -6823,6 +7068,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -7214,7 +7496,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -9142,6 +9423,15 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/node-releases": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
@@ -9854,6 +10144,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -9874,6 +10173,24 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -10030,6 +10347,12 @@
"node": ">= 4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -11935,6 +12258,24 @@
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
"dev": "next dev",
"build": "npm run build -w @mmapp/contracts && next build",
"postinstall": "npm run build -w @mmapp/contracts",
"prepare": "node .ci/scripts/ensure-git-hooks.mjs",
"start": "next start",
"lint": "eslint"
},
+8
View File
@@ -25,6 +25,14 @@
"./events": {
"types": "./dist/events.d.ts",
"default": "./dist/events.js"
},
"./certificates": {
"types": "./dist/certificates.d.ts",
"default": "./dist/certificates.js"
},
"./backups": {
"types": "./dist/backups.d.ts",
"default": "./dist/backups.js"
}
},
"dependencies": {
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod"
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
export const backupFormatSchema = z.enum(["rsc", "backup"])
export const backupScheduleSettingsDtoSchema = z.object({
enabled: z.boolean(),
frequency: backupFrequencySchema,
hour: z.number().int().min(0).max(23),
minute: z.number().int().min(0).max(59),
weekDay: z.number().int().min(0).max(6),
monthDay: z.number().int().min(1).max(28),
keepCount: z.number().int().min(1).max(365),
format: backupFormatSchema,
serverIds: z.array(z.string()),
lastRunAt: z.string().nullable().optional(),
lastDurationMs: z.number().int().nullable().optional(),
lastError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putBackupScheduleSettingsSchema = z.object({
enabled: z.boolean().optional(),
frequency: backupFrequencySchema.optional(),
hour: z.number().int().min(0).max(23).optional(),
minute: z.number().int().min(0).max(59).optional(),
weekDay: z.number().int().min(0).max(6).optional(),
monthDay: z.number().int().min(1).max(28).optional(),
keepCount: z.number().int().min(1).max(365).optional(),
format: backupFormatSchema.optional(),
serverIds: z.array(z.string()).optional(),
})
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
+127
View File
@@ -0,0 +1,127 @@
import { z } from "zod"
export const certUsageSchema = z.enum([
"server",
"client",
"ca",
"crl-sign",
])
export const certStatusSchema = z.enum(["valid", "expired", "revoked"])
export const certificateDtoSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string().optional(),
commonName: z.string(),
sans: z.array(z.string()),
issuedBy: z.string(),
validFrom: z.string(),
validUntil: z.string(),
daysLeft: z.number().int(),
keySize: z.number().int().nonnegative(),
usage: z.array(certUsageSchema),
trusted: z.boolean(),
status: certStatusSchema,
acmeStatus: z.string().optional(),
})
export const certificatesListResponseSchema = z.object({
certificates: z.array(certificateDtoSchema),
failures: z.array(z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
})),
})
export const certificateTrustStoreSchema = z.enum([
"www",
"api",
"sstp",
"ovpn",
"ipsec",
"email",
"radius",
"fetch",
"container",
])
export const certificateIssueRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
certName: z.string().min(1).max(64),
domainNames: z.array(z.string().min(1)).min(1),
keyType: z.enum(["rsa2048", "ec256"]).optional(),
trustStore: z.array(certificateTrustStoreSchema).optional(),
})
export const certificateIssueJobStatusSchema = z.enum([
"queued",
"running",
"done",
"failed",
])
export const certificateIssueStepSchema = z.enum([
"queued",
"acme_order",
"dns_challenge",
"finalize",
"import",
"cleanup",
"done",
])
export const certificateIssueJobSchema = z.object({
id: z.string().min(1),
status: certificateIssueJobStatusSchema,
step: certificateIssueStepSchema,
serverId: z.string(),
certName: z.string(),
domainNames: z.array(z.string()),
requestedAt: z.string(),
startedAt: z.string().optional(),
finishedAt: z.string().optional(),
error: z.string().optional(),
})
export const acmeCloudflareSettingsDtoSchema = z.object({
directoryUrl: z.string().url(),
defaultZoneId: z.string().optional(),
tokenConfigured: z.boolean(),
updatedAt: z.string().optional(),
})
export const putAcmeCloudflareSettingsSchema = z.object({
directoryUrl: z.string().url().optional(),
defaultZoneId: z.union([z.string(), z.null()]).optional(),
cloudflareApiToken: z.union([z.string(), z.null()]).optional(),
})
export const testAcmeCloudflareSettingsSchema = z.object({
cloudflareApiToken: z.string().optional(),
})
export const certificateRenewSettingsDtoSchema = z.object({
enabled: z.boolean(),
intervalSec: z.number().int().min(300),
renewBeforeDays: z.number().int().min(1).max(90),
lastCollectedAt: z.string().nullable().optional(),
lastDurationMs: z.number().int().nullable().optional(),
lastError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putCertificateRenewSettingsSchema = z.object({
enabled: z.boolean().optional(),
intervalSec: z.number().int().min(300).optional(),
renewBeforeDays: z.number().int().min(1).max(90).optional(),
})
export type CertificateDto = z.infer<typeof certificateDtoSchema>
export type CertificatesListResponse = z.infer<typeof certificatesListResponseSchema>
export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSchema>
export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema>
export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema>
export type CertificateRenewSettingsDto = z.infer<typeof certificateRenewSettingsDtoSchema>
+1
View File
@@ -8,6 +8,7 @@ export const eventSourceModuleSchema = z.enum([
"filters",
"traffic",
"alerts",
"certificates",
"system",
])
+2
View File
@@ -1,3 +1,5 @@
export * from "./servers.js"
export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
+16 -1
View File
@@ -1,4 +1,5 @@
import { requestJson } from "@/shared/api/http-client"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
export type BackupItem = {
id: string
@@ -7,7 +8,7 @@ export type BackupItem = {
filename: string
sizeBytes: number
createdAt: string
kind: "manual"
kind: "manual" | "auto"
notes?: string
}
@@ -66,3 +67,17 @@ export async function getBackupJob(baseUrl: string, jobId: string): Promise<Back
export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
}
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
}
export async function putBackupScheduleSettings(
baseUrl: string,
payload: Partial<BackupScheduleSettingsDto>,
): Promise<BackupScheduleSettingsDto> {
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule", {
method: "PUT",
body: JSON.stringify(payload),
})
}
+79
View File
@@ -0,0 +1,79 @@
import type {
AcmeCloudflareSettingsDto,
CertificateIssueJob,
CertificateIssueRequest,
CertificateRenewSettingsDto,
CertificatesListResponse,
} from "@mmapp/contracts/certificates"
import { requestJson } from "@/shared/api/http-client"
export async function listCertificates(baseUrl: string): Promise<CertificatesListResponse> {
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates")
}
export async function refreshCertificates(baseUrl: string): Promise<CertificatesListResponse> {
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates/refresh", { method: "POST" })
}
export async function createCertificateIssueJob(
baseUrl: string,
payload: CertificateIssueRequest,
): Promise<{ jobId: string }> {
return requestJson<{ jobId: string }>(baseUrl, "/api/certificates/issue", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function getCertificateIssueJob(
baseUrl: string,
jobId: string,
): Promise<CertificateIssueJob> {
return requestJson<CertificateIssueJob>(baseUrl, `/api/certificates/issue/${encodeURIComponent(jobId)}`)
}
export async function getAcmeSettings(baseUrl: string): Promise<AcmeCloudflareSettingsDto> {
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings")
}
export async function putAcmeSettings(
baseUrl: string,
payload: {
directoryUrl?: string
defaultZoneId?: string | null
cloudflareApiToken?: string | null
},
): Promise<AcmeCloudflareSettingsDto> {
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings", {
method: "PUT",
body: JSON.stringify(payload),
})
}
export async function testAcmeSettings(
baseUrl: string,
payload?: { cloudflareApiToken?: string },
): Promise<{ ok: boolean; message?: string }> {
return requestJson<{ ok: boolean; message?: string }>(baseUrl, "/api/certificates/acme-settings/test", {
method: "POST",
body: JSON.stringify(payload ?? {}),
})
}
export async function getCertificateRenewSettings(baseUrl: string): Promise<CertificateRenewSettingsDto> {
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings")
}
export async function putCertificateRenewSettings(
baseUrl: string,
payload: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
},
): Promise<CertificateRenewSettingsDto> {
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify(payload),
})
}
+10 -1
View File
@@ -1,3 +1,5 @@
import { configuredBackendUrl } from "@/lib/backend-url"
export class ApiClientError extends Error {
constructor(
message: string,
@@ -13,13 +15,20 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveRequestUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path
}
return trimBaseUrl(baseUrl) + path
}
export async function requestJson<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const res = await fetch(trimBaseUrl(baseUrl) + path, {
const res = await fetch(resolveRequestUrl(baseUrl, path), {
...init,
headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}),
+1 -1
View File
File diff suppressed because one or more lines are too long