Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f | ||
|
|
63aa9d424b | ||
|
|
43ab17b253 | ||
|
|
4b8cf83ee9 | ||
|
|
cdaf3deb03 | ||
|
|
3ce4179f3c | ||
|
|
1a47905beb | ||
|
|
b51d979a1c | ||
|
|
bc01da1708 | ||
|
|
500a543045 | ||
|
|
60024facab | ||
|
|
53ea0e74a3 | ||
|
|
6c8ca17590 | ||
|
|
d62d212c86 | ||
|
|
bfceb07a23 | ||
|
|
e7a8ad0910 | ||
|
|
0435608ef9 | ||
|
|
c2a8a08618 |
@@ -1,190 +1,50 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.0",
|
"version": "1.3.4",
|
||||||
"tag": "v1.1.0",
|
"tag": "v1.3.4",
|
||||||
"publishedAt": "2026-05-12T10:30:14.398Z",
|
"publishedAt": "2026-05-12T13:50:06.198Z",
|
||||||
"releaseUrl": "https://git.shts.su/denozord/MikrotikManager-3/releases/tag/v1.1.0",
|
"releaseUrl": "",
|
||||||
"commits": [
|
"commits": [
|
||||||
{
|
{
|
||||||
"sha": "4b92a6eb81ce64fad2ca61f5fa94342959a45349",
|
"sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
|
||||||
"shortSha": "4b92a6e",
|
"shortSha": "3ce4179",
|
||||||
"subject": "feat: release v1.1.0 with enhancements and refactoring",
|
"subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
|
||||||
"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",
|
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "chore"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4489dfb2d62e836b5415b9ead409851bef995eaf",
|
"sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
|
||||||
"shortSha": "4489dfb",
|
"shortSha": "1a47905",
|
||||||
"subject": "chore: enhance frontend configuration for backend integration",
|
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4b240d70d0fbc2143cc657f78f932d003ad5d406",
|
"sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
|
||||||
"shortSha": "4b240d7",
|
"shortSha": "b51d979",
|
||||||
"subject": "chore: add server and server_snapshots tables to database schema",
|
"subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "63252ae85ed03b772ede2be97a27f1605cc16d0d",
|
"sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
|
||||||
"shortSha": "63252ae",
|
"shortSha": "bc01da1",
|
||||||
"subject": "chore: enhance image reference resolution in updater script",
|
"subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "a359874a42add6b0f4f2f4844ba6e19268bdc05f",
|
"sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
|
||||||
"shortSha": "a359874",
|
"shortSha": "500a543",
|
||||||
"subject": "chore: enhance Dockerfile for backend to rebuild better-sqlite3",
|
"subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha": "4ec247f2c02edfccd775c3e5c644c28844358c90",
|
"sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
|
||||||
"shortSha": "4ec247f",
|
"shortSha": "60024fa",
|
||||||
"subject": "chore: update Docker Compose configuration for data volume management",
|
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
|
||||||
"author": "Denozordec",
|
"author": "Denozordec",
|
||||||
"type": "chore"
|
"type": "fix"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
- fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов (`1a47905`, Denozordec)
|
||||||
- feat: update application versioning and enhance release management (`9ccc845`, Denozordec)
|
- fix(backend): улучшить обработку хранилищ доверия для сертификатов (`b51d979`, Denozordec)
|
||||||
- feat: enhance network configuration handling in updater script (`f282585`, Denozordec)
|
- fix(backend): добавить функции для работы с IP-адресами и DNS-записями (`bc01da1`, Denozordec)
|
||||||
- feat: refactor theme management and remove next-themes dependency (`40e2040`, Denozordec)
|
- fix(backend): улучшить уникальность идентификаторов сертификатов (`500a543`, Denozordec)
|
||||||
- feat: implement database backup and restore functionality in settings page (`bb07ba0`, Denozordec)
|
- fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов (`60024fa`, Denozordec)
|
||||||
- feat: implement internet path functionality with backend support (`b9a75b6`, Denozordec)
|
- fix(ui): добавить новые компоненты для управления сертификатами (`53ea0e7`, Denozordec)
|
||||||
- feat: integrate sonner for toast notifications and enhance UI feedback (`11ad94f`, Denozordec)
|
- fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки (`6c8ca17`, Denozordec)
|
||||||
- feat: enhance backups page with live data loading and backup job management (`84ecd4f`, Denozordec)
|
|
||||||
|
|
||||||
## chore
|
## chore
|
||||||
|
|
||||||
- chore: enhance data source configuration and mock data handling (`1fefed2`, Denozordec)
|
- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
|
||||||
- chore: enhance frontend configuration for backend integration (`4489dfb`, Denozordec)
|
- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, 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)
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
|
|||||||
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
||||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
|
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 =
|
const CONVENTIONAL_RE =
|
||||||
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
||||||
const MINOR_TYPES = new Set(["feat"])
|
const MINOR_TYPES = new Set(["feat"])
|
||||||
@@ -139,6 +140,23 @@ function getCommitsSince(tag) {
|
|||||||
)
|
)
|
||||||
if (!raw) return []
|
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
|
return raw
|
||||||
.split("\x1e")
|
.split("\x1e")
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
@@ -210,7 +228,7 @@ function main() {
|
|||||||
if (commits.length === 0) {
|
if (commits.length === 0) {
|
||||||
const deployTag = latestTag ?? `v${baseFromTag}`
|
const deployTag = latestTag ?? `v${baseFromTag}`
|
||||||
const deployVersion = baseFromTag
|
const deployVersion = baseFromTag
|
||||||
const displayCommits = latestTag ? getCommitsSince(getPreviousTag(latestTag)) : []
|
const displayCommits = getRecentCommits()
|
||||||
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true })
|
mkdirSync(outputDir, { recursive: true })
|
||||||
@@ -244,7 +262,7 @@ function main() {
|
|||||||
tag,
|
tag,
|
||||||
publishedAt,
|
publishedAt,
|
||||||
releaseUrl,
|
releaseUrl,
|
||||||
commits: toManifestCommits(commits),
|
commits: toManifestCommits(getRecentCommits()),
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true })
|
mkdirSync(outputDir, { recursive: true })
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
|
||||||
|
const gitCheck = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (gitCheck.status !== 0) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevel = gitCheck.stdout.trim()
|
||||||
|
|
||||||
|
execFileSync("git", ["config", "core.hooksPath", ".githooks"], {
|
||||||
|
cwd: topLevel,
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { StatusBadge } from "@/components/status-badge"',
|
||||||
|
'import { useEffect, useMemo, useState } from "react"\n'
|
||||||
|
'import { PageHeader } from "@/components/page-header"\n'
|
||||||
|
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
|
||||||
|
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
|
||||||
|
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
""",
|
||||||
|
"""import {
|
||||||
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
|
""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"""import {
|
||||||
|
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
"""import {
|
||||||
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
|
EyeIcon, EyeOffIcon,
|
||||||
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
|
ShieldIcon, WifiIcon,
|
||||||
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
|
} from "lucide-react\"""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
|
||||||
|
"// ─── Countries ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"// ─── Type config ─.*?// ─── Shared small components ─",
|
||||||
|
"// ─── Shared small components ─",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
|
||||||
|
"// ─── Country field",
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S | re.M,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
"const [expandedId, setExpandedId] = useState<string | null>(null)",
|
||||||
|
"const [sheetStep, setSheetStep] = useState(1)",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
|
||||||
|
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
'setTestState("idle"); setTestMsg(""); setOpen(true)',
|
||||||
|
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = re.sub(r"<Field\b", "<FormField", text)
|
||||||
|
text = re.sub(r"</Field>", "</FormField>", text)
|
||||||
|
text = re.sub(r"<Toggle\b", "<FormToggle", text)
|
||||||
|
|
||||||
|
new_table = """ {/* Table */}
|
||||||
|
<Card>
|
||||||
|
<DataPageToolbar
|
||||||
|
segmented={{
|
||||||
|
value: typeFilter,
|
||||||
|
onChange: setTypeFilter,
|
||||||
|
options: tabs.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
|
countLabel={`${filtered.length} серверов`}
|
||||||
|
/>
|
||||||
|
<ServersDataGrid
|
||||||
|
servers={filtered}
|
||||||
|
isLive={isLive}
|
||||||
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = re.sub(
|
||||||
|
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
|
||||||
|
new_table,
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("patched", path)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Wrap sheet form in stepper
|
||||||
|
text = text.replace(
|
||||||
|
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* 1. Основные */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Основные</SectionTitle>""",
|
||||||
|
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 2. WAN-аплинки (только для home-router) */}
|
||||||
|
{isHomeRouter && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 3. Подключение (API) */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
{/* 4. Дополнительно */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
""" </StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" </div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
|
<Button className="flex-1" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>""",
|
||||||
|
""" </StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
|
{sheetStep > 1 && (
|
||||||
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WAN step 2: show message when not home router
|
||||||
|
text = text.replace(
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
|
<WanUplinkEditor""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
""" />
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
""" />
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3}""",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print("stepper patched")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs"
|
||||||
|
|
||||||
|
const messagePath = process.argv[2]
|
||||||
|
|
||||||
|
if (!messagePath) {
|
||||||
|
console.error("validate-commit-message: не указан файл сообщения коммита")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.SKIP_COMMIT_MESSAGE_RU === "1") {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(messagePath, "utf8")
|
||||||
|
const subject = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0 && !line.startsWith("#"))
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipPatterns = [
|
||||||
|
/^Merge\b/i,
|
||||||
|
/^Revert\b/i,
|
||||||
|
/^fixup!/i,
|
||||||
|
/^squash!/i,
|
||||||
|
/^amend!/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (skipPatterns.some((pattern) => pattern.test(subject))) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cyrillic = /[\u0400-\u04FF\u0500-\u052F]/
|
||||||
|
|
||||||
|
if (!cyrillic.test(subject)) {
|
||||||
|
console.error("")
|
||||||
|
console.error("Коммит отклонён: subject должен быть на русском.")
|
||||||
|
console.error("Формат: fix(scope): краткое описание на русском")
|
||||||
|
console.error("Правила: .cursor/rules/commit-messages-ru.mdc")
|
||||||
|
console.error("Обход только для аварий: SKIP_COMMIT_MESSAGE_RU=1")
|
||||||
|
console.error("")
|
||||||
|
console.error(`Subject: ${subject}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const conventional =
|
||||||
|
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:\s*(.+)$/
|
||||||
|
|
||||||
|
const match = subject.match(conventional)
|
||||||
|
|
||||||
|
if (match && !cyrillic.test(match[3])) {
|
||||||
|
console.error("")
|
||||||
|
console.error("Коммит отклонён: текст после «:» должен быть на русском.")
|
||||||
|
console.error(`Subject: ${subject}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
@@ -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, правил, документации и багфиксов.
|
||||||
@@ -5,15 +5,40 @@ alwaysApply: true
|
|||||||
|
|
||||||
# Локальный запуск проекта
|
# Локальный запуск проекта
|
||||||
|
|
||||||
Для запуска всего проекта в dev-режиме поднимать два процесса:
|
## Требования
|
||||||
|
|
||||||
|
- **Node.js 22**, npm с workspaces.
|
||||||
|
- Первый раз (или после смены зависимостей): `npm install` из корня репозитория.
|
||||||
|
- Backend: скопировать `backend/.env.example` → `backend/.env` (по умолчанию `PORT=8000`, `CORS_ORIGIN=http://localhost:3000`).
|
||||||
|
|
||||||
|
## Запуск (два процесса)
|
||||||
|
|
||||||
|
Из корня репозитория поднять **два** long-running процесса в **отдельных** терминалах:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run dev
|
npm run dev
|
||||||
npm --prefix backend run dev
|
npm --prefix backend run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- Frontend: `http://localhost:3000`
|
| Сервис | URL | Проверка |
|
||||||
- Backend: `http://localhost:8000`
|
|--------|-----|----------|
|
||||||
- Health check backend: `http://localhost:8000/health`
|
| Frontend (Next.js 16, Turbopack) | http://localhost:3000 | открыть в браузере |
|
||||||
|
| Backend (Fastify) | http://localhost:8000 | `GET /health` |
|
||||||
|
|
||||||
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
|
Проверка backend в PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:8000/health -UseBasicParsing | Select-Object -ExpandProperty Content
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемый ответ: `{"status":"ok",...}`.
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
Если пользователь просит «запусти проект», «запусти фронт и бэк» или похожее:
|
||||||
|
|
||||||
|
1. Сначала проверить уже запущенные терминалы — не дублировать процессы.
|
||||||
|
2. Запустить обе команды выше как фоновые long-running процессы.
|
||||||
|
3. Дождаться готовности: frontend — `Ready`, backend — `Server listening` / успешный `/health`.
|
||||||
|
|
||||||
|
Подробности архитектуры и env — `README.md`, раздел «Запуск».
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ alwaysApply: false
|
|||||||
## Связь с проектом
|
## Связь с проектом
|
||||||
|
|
||||||
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||||
- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.mdc`**.
|
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ alwaysApply: false
|
|||||||
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||||
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
|
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
|
||||||
|
|
||||||
## Формат вывода (строго)
|
## Формат вывода (строго)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ alwaysApply: true
|
|||||||
|
|
||||||
**Язык:** все ответы пользователю — **только на русском**.
|
**Язык:** все ответы пользователю — **только на русском**.
|
||||||
|
|
||||||
|
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
|
||||||
|
|
||||||
## 1. Документация в первую очередь
|
## 1. Документация в первую очередь
|
||||||
|
|
||||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
---
|
---
|
||||||
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
|
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||||
alwaysApply: true
|
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).
|
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||||
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||||
|
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||||
|
|
||||||
## Patch (`1.0.x`)
|
## Patch (`1.0.x`)
|
||||||
|
|
||||||
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||||
|
|
||||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
|
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||||
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||||
|
|
||||||
## Minor (`1.x.0`)
|
## Minor (`1.x.0`)
|
||||||
@@ -23,10 +35,11 @@ alwaysApply: true
|
|||||||
|
|
||||||
- `fix(servers): исправить опрос API после таймаута` — patch
|
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||||
- `feat(filters): добавить синхронизацию BGP in` — minor
|
- `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`.
|
Подробности — раздел CI/CD в `README.md`.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Git commit message (Generate Commit Message)
|
||||||
|
|
||||||
|
Всегда генерируй subject коммита на русском.
|
||||||
|
|
||||||
|
- Формат: `тип(scope): описание на русском` — тип и scope на английском, текст после `:` только на русском.
|
||||||
|
- По умолчанию не `feat`: правки UI/багов/CI → `fix`; правила/README/workflow → `chore`/`ci`; `feat` только для новой пользовательской возможности.
|
||||||
|
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`.
|
||||||
|
- Запрещено: `feat: update layout`, `fix: add modal`, любой английский subject.
|
||||||
|
|
||||||
|
Подробнее: `.cursor/rules/commit-messages-ru.mdc`, semver: `.cursor/rules/release-versioning.mdc`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
node "$(dirname "$0")/../.ci/scripts/validate-commit-message.mjs" "$1"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# MikrotikManager — инструкции для агента
|
||||||
|
|
||||||
|
## Сообщения коммитов
|
||||||
|
|
||||||
|
При **Generate Commit Message** и любой автогенерации commit message:
|
||||||
|
|
||||||
|
1. Subject после `:` — **только на русском**.
|
||||||
|
2. **`feat` не по умолчанию.** Правка UI/бага/CI → `fix`; правила/README/workflow → `chore`/`ci`; новая пользовательская фича → `feat`.
|
||||||
|
3. Формат: `fix(ui): выровнять модалку релиза`.
|
||||||
|
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||||
|
|
||||||
|
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||||
@@ -40,6 +40,7 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
ENV BACKEND_INTERNAL_URL=http://backend:8000
|
||||||
COPY --from=build /app/public ./public
|
COPY --from=build /app/public ./public
|
||||||
COPY --from=build /app/.next/standalone ./
|
COPY --from=build /app/.next/standalone ./
|
||||||
COPY --from=build /app/.next/static ./.next/static
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
|||||||
@@ -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>`.
|
- 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 подставляется из артефакта).
|
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
||||||
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
- 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
|
## Прод-развёртывание Docker
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
|
|||||||
|
|
||||||
// ─── small helpers ────────────────────────────────────────────────────────────
|
// ─── small helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
|
||||||
return (
|
|
||||||
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
|
|
||||||
onClick={() => { if (!disabled) onChange(!checked) }}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
|
||||||
disabled && "opacity-50 pointer-events-none",
|
|
||||||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0.5",
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
|
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
|
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
|
||||||
}
|
}
|
||||||
@@ -739,7 +723,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
|||||||
!rule.enabled && "opacity-55",
|
!rule.enabled && "opacity-55",
|
||||||
)}>
|
)}>
|
||||||
{/* toggle */}
|
{/* toggle */}
|
||||||
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
<FormToggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
||||||
|
|
||||||
{/* severity dot */}
|
{/* severity dot */}
|
||||||
<SeverityDot severity={rule.severity} />
|
<SeverityDot severity={rule.severity} />
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { asns as mockAsns } from "@/lib/data"
|
import { asns as mockAsns } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function AsnsPage() {
|
export default function AsnsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ export default function AsnsPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||||
</>
|
</>
|
||||||
@@ -57,6 +62,7 @@ export default function AsnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
searchKeys={["asn", "org", "prefixes"]}
|
searchKeys={["asn", "org", "prefixes"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -106,6 +112,16 @@ export default function AsnsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт ASN"
|
||||||
|
description="Загрузите CSV или JSON со списком автономных систем"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+220
-207
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import type { Backup, Server } from "@/lib/data"
|
import type { Backup, Server } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -20,54 +24,19 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { listServers } from "@/shared/api/servers"
|
import { listServers } from "@/shared/api/servers"
|
||||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
Stepper,
|
||||||
|
StepperContent,
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
StepperIndicator,
|
||||||
return (
|
StepperItem,
|
||||||
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
StepperNav,
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
|
StepperPanel,
|
||||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
StepperSeparator,
|
||||||
</button>
|
StepperTitle,
|
||||||
)
|
StepperTrigger,
|
||||||
}
|
} from "@/components/reui/stepper"
|
||||||
|
|
||||||
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 pb-1">
|
|
||||||
<span className="text-muted-foreground">{icon}</span>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">{label}</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
||||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((o) => (
|
|
||||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -116,6 +85,7 @@ export default function BackupsPage() {
|
|||||||
const [opBusy, setOpBusy] = useState(false)
|
const [opBusy, setOpBusy] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||||||
|
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||||
|
|
||||||
// Schedule
|
// Schedule
|
||||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||||
@@ -140,13 +110,38 @@ export default function BackupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
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
|
// Manual backup sheet
|
||||||
const [manualOpen, setManualOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
|
const [manualStep, setManualStep] = useState(1)
|
||||||
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||||
const [manualNotes, setManualNotes] = useState("")
|
const [manualNotes, setManualNotes] = useState("")
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||||
function toggleManualServer(id: string) {
|
function toggleManualServer(id: string) {
|
||||||
setManualServers((prev) => {
|
setManualServers((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -171,14 +166,28 @@ export default function BackupsPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
try {
|
||||||
const [serversRows, backupsRows] = await Promise.all([
|
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
|
||||||
listServers(backendUrl),
|
listServers(backendUrl),
|
||||||
listBackups(backendUrl),
|
listBackups(backendUrl),
|
||||||
|
getBackupScheduleSettings(backendUrl),
|
||||||
])
|
])
|
||||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
||||||
setLiveServers(mappedServers)
|
setLiveServers(mappedServers)
|
||||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
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) {
|
} catch (e) {
|
||||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -319,7 +328,7 @@ export default function BackupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />Новый бэкап
|
<PlusIcon className="size-4" />Новый бэкап
|
||||||
@@ -388,81 +397,27 @@ export default function BackupsPage() {
|
|||||||
{/* ── История ──────────────────────────────────────────────────── */}
|
{/* ── История ──────────────────────────────────────────────────── */}
|
||||||
{tab === "history" && (
|
{tab === "history" && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{([
|
value: kindFilter,
|
||||||
{ value: "all", label: "Все", count: backupList.length },
|
onChange: setKindFilter,
|
||||||
{ value: "auto", label: "Авто", count: autoCount },
|
options: [
|
||||||
{ value: "manual", label: "Вручную", count: manualCount },
|
{ value: "all", label: "Все", count: backupList.length },
|
||||||
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
|
{ value: "auto", label: "Авто", count: autoCount },
|
||||||
<button key={t.value} onClick={() => setKindFilter(t.value)}
|
{ value: "manual", label: "Вручную", count: manualCount },
|
||||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
],
|
||||||
{t.label}
|
}}
|
||||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
countLabel={`${filtered.length} бэкапов`}
|
||||||
</button>
|
/>
|
||||||
))}
|
<BackupsDataGrid
|
||||||
</div>
|
backups={filtered}
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
onDownload={handleDownload}
|
||||||
</div>
|
onRestore={(b) => {
|
||||||
|
setRestoreTarget(b)
|
||||||
<div className="overflow-x-auto">
|
setRestoreOpen(true)
|
||||||
<table className="w-full text-sm">
|
}}
|
||||||
<thead>
|
onDelete={handleDelete}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
/>
|
||||||
<th className="text-left font-medium px-5 py-3">Файл</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Сервер</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Размер</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Тип</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Заметки</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Создан</th>
|
|
||||||
<th className="w-28 px-3 py-3" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
|
|
||||||
)}
|
|
||||||
{filtered.map((b) => (
|
|
||||||
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
|
|
||||||
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
|
|
||||||
b.kind === "manual"
|
|
||||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
|
||||||
: "bg-muted text-muted-foreground border-border"
|
|
||||||
)}>
|
|
||||||
{b.kind === "auto" ? "авто" : "вручную"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
|
||||||
<td className="px-3 py-3">
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7"
|
|
||||||
title="Скачать"
|
|
||||||
onClick={() => void handleDownload(b.id, b.filename)}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
|
||||||
title="Удалить" onClick={() => void handleDelete(b.id)}>
|
|
||||||
<Trash2Icon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -480,11 +435,11 @@ export default function BackupsPage() {
|
|||||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||||
<Field label="Частота">
|
<FormField label="Частота">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.frequency}
|
value={schedule.frequency}
|
||||||
onChange={(v) => setSched("frequency", v)}
|
onChange={(v) => setSched("frequency", v)}
|
||||||
@@ -494,10 +449,10 @@ export default function BackupsPage() {
|
|||||||
{ value: "monthly", label: "Ежемесячно" },
|
{ value: "monthly", label: "Ежемесячно" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{schedule.frequency === "weekly" && (
|
{schedule.frequency === "weekly" && (
|
||||||
<Field label="День недели">
|
<FormField label="День недели">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{WEEK_DAYS.map((d, i) => (
|
{WEEK_DAYS.map((d, i) => (
|
||||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||||
@@ -511,18 +466,18 @@ export default function BackupsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedule.frequency === "monthly" && (
|
{schedule.frequency === "monthly" && (
|
||||||
<Field label="День месяца" hint="1–28">
|
<FormField label="День месяца" hint="1–28">
|
||||||
<Input type="number" min={1} max={28} className="font-mono w-24"
|
<Input type="number" min={1} max={28} className="font-mono w-24"
|
||||||
value={schedule.monthDay}
|
value={schedule.monthDay}
|
||||||
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Время запуска">
|
<FormField label="Время запуска">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||||||
@@ -544,15 +499,15 @@ export default function BackupsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||||
<Input type="number" min={1} max={90} className="font-mono"
|
<Input type="number" min={1} max={90} className="font-mono"
|
||||||
value={schedule.keepCount}
|
value={schedule.keepCount}
|
||||||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Формат файла">
|
<FormField label="Формат файла">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={schedule.format}
|
value={schedule.format}
|
||||||
onChange={(v) => setSched("format", v)}
|
onChange={(v) => setSched("format", v)}
|
||||||
@@ -561,7 +516,7 @@ export default function BackupsPage() {
|
|||||||
{ value: "backup", label: ".backup" },
|
{ value: "backup", label: ".backup" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -572,7 +527,7 @@ export default function BackupsPage() {
|
|||||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||||
|
|
||||||
<Field label="Тип хранилища">
|
<FormField label="Тип хранилища">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={storage.type}
|
value={storage.type}
|
||||||
onChange={(v) => setStore("type", v)}
|
onChange={(v) => setStore("type", v)}
|
||||||
@@ -583,45 +538,45 @@ export default function BackupsPage() {
|
|||||||
{ value: "smb", label: "SMB" },
|
{ value: "smb", label: "SMB" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
{storage.type === "local" && (
|
{storage.type === "local" && (
|
||||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||||
value={storage.localPath}
|
value={storage.localPath}
|
||||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{storage.type !== "local" && (
|
{storage.type !== "local" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Field label="Хост">
|
<FormField label="Хост">
|
||||||
<Input className="font-mono" placeholder="192.168.1.100"
|
<Input className="font-mono" placeholder="192.168.1.100"
|
||||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Порт">
|
<FormField label="Порт">
|
||||||
<Input className="font-mono"
|
<Input className="font-mono"
|
||||||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||||||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{storage.type === "smb" && (
|
{storage.type === "smb" && (
|
||||||
<Field label="Общая папка (Share)">
|
<FormField label="Общая папка (Share)">
|
||||||
<Input className="font-mono" placeholder="backups"
|
<Input className="font-mono" placeholder="backups"
|
||||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Пользователь">
|
<FormField label="Пользователь">
|
||||||
<Input className="font-mono" placeholder="backup-user"
|
<Input className="font-mono" placeholder="backup-user"
|
||||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={storage.showPassword ? "text" : "password"}
|
type={storage.showPassword ? "text" : "password"}
|
||||||
@@ -636,13 +591,13 @@ export default function BackupsPage() {
|
|||||||
{storage.showPassword ? "скрыть" : "показ"}
|
{storage.showPassword ? "скрыть" : "показ"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Удалённый путь">
|
<FormField label="Удалённый путь">
|
||||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -711,7 +666,7 @@ export default function BackupsPage() {
|
|||||||
|
|
||||||
{/* Save button */}
|
{/* Save button */}
|
||||||
<div className="lg:col-span-2 flex items-center gap-3">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -722,71 +677,129 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
||||||
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
|
<Sheet open={manualOpen} onOpenChange={(v) => { setManualOpen(v); if (!v) setManualStep(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Новый бэкап</SheetTitle>
|
<SheetTitle>Новый бэкап</SheetTitle>
|
||||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<Stepper value={manualStep} onValueChange={setManualStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
<div className="flex flex-col gap-3">
|
<StepperNav className="mb-5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<StepperItem step={1}>
|
||||||
<p className="text-sm font-medium">Выберите серверы</p>
|
<StepperTrigger>
|
||||||
<div className="flex items-center gap-2">
|
<StepperIndicator>1</StepperIndicator>
|
||||||
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
<StepperTitle className="sr-only">Серверы</StepperTitle>
|
||||||
className="text-xs text-primary hover:underline">Все</button>
|
</StepperTrigger>
|
||||||
<span className="text-border">·</span>
|
<StepperSeparator />
|
||||||
<button type="button" onClick={() => setManualServers(new Set())}
|
</StepperItem>
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Заметка</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-sm font-medium">Выберите серверы</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={() => setManualServers(new Set(liveServers.map((s) => s.id)))}
|
||||||
|
className="text-xs text-primary hover:underline">Все</button>
|
||||||
|
<span className="text-border">·</span>
|
||||||
|
<button type="button" onClick={() => setManualServers(new Set())}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{liveServers.map((s) => {
|
||||||
{liveServers.map((s) => {
|
const checked = manualServers.has(s.id)
|
||||||
const checked = manualServers.has(s.id)
|
return (
|
||||||
return (
|
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
className={cn(
|
||||||
className={cn(
|
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
|
||||||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
|
)}>
|
||||||
)}>
|
<div className={cn(
|
||||||
<div className={cn(
|
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
checked ? "bg-primary border-primary" : "border-border"
|
||||||
checked ? "bg-primary border-primary" : "border-border"
|
)}>
|
||||||
)}>
|
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium">{s.name}</p>
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
|
||||||
<StatusBadge status={s.status} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
{s.status === "offline" && (
|
<p className="text-sm font-medium">{s.name}</p>
|
||||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
)}
|
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||||||
</button>
|
<StatusBadge status={s.status} />
|
||||||
)
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
{s.status === "offline" && (
|
||||||
|
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<label className="text-sm font-medium">Заметка</label>
|
</button>
|
||||||
<Input placeholder="Например: перед обновлением BGP"
|
)
|
||||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
})}
|
||||||
</div>
|
</StepperContent>
|
||||||
</div>
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
|
<FormField label="Заметка">
|
||||||
|
<Input placeholder="Например: перед обновлением BGP"
|
||||||
|
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Будет создан бэкап для <strong className="text-foreground">{manualServers.size}</strong> серверов.
|
||||||
|
</p>
|
||||||
|
{manualNotes && (
|
||||||
|
<p className="text-muted-foreground">Заметка: {manualNotes}</p>
|
||||||
|
)}
|
||||||
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1"
|
{manualStep > 1 && (
|
||||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
|
||||||
onClick={handleManualBackup}>
|
Назад
|
||||||
Снять бэкап ({manualServers.size})
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
|
{manualStep < 3 ? (
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
disabled={manualStep === 1 && manualServers.size === 0}
|
||||||
|
onClick={() => setManualStep((s) => s + 1)}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="flex-1"
|
||||||
|
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||||
|
onClick={handleManualBackup}>
|
||||||
|
Снять бэкап ({manualServers.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={restoreOpen}
|
||||||
|
onOpenChange={setRestoreOpen}
|
||||||
|
title={restoreTarget ? `Восстановление: ${restoreTarget.filename}` : "Восстановление бэкапа"}
|
||||||
|
description="Выберите файл конфигурации для загрузки на роутер"
|
||||||
|
accept=".backup,.rsc,.zip"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.success(`Файл ${files[0]?.name} подготовлен к восстановлению на ${restoreTarget?.server ?? "сервер"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-293
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
import { Fragment, useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
|
import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
||||||
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -17,49 +24,12 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
type BgpSession = BgpSessionRow
|
||||||
type BgpType = "eBGP" | "iBGP"
|
|
||||||
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
|
||||||
type BgpTab = "sessions" | "routers" | "analytics"
|
type BgpTab = "sessions" | "routers" | "analytics"
|
||||||
type StateFilter = "all" | BgpState
|
type StateFilter = "all" | BgpState
|
||||||
type TypeFilter = "all" | BgpType
|
type TypeFilter = "all" | BgpType
|
||||||
|
|
||||||
interface BgpSession {
|
const AS_NAMES = BGP_AS_NAMES
|
||||||
id: string
|
|
||||||
serverId: string
|
|
||||||
serverLabel: string
|
|
||||||
serverSite: string
|
|
||||||
peerIp: string
|
|
||||||
remoteAs: number
|
|
||||||
localAs: number
|
|
||||||
routerId: string
|
|
||||||
description: string
|
|
||||||
state: BgpState
|
|
||||||
type: BgpType
|
|
||||||
afi: BgpAfi
|
|
||||||
uptime: string | null
|
|
||||||
holdTime: number
|
|
||||||
keepalive: number
|
|
||||||
prefixesRx: number
|
|
||||||
prefixesTx: number
|
|
||||||
prefixesActive: number
|
|
||||||
inputMessages: number
|
|
||||||
outputMessages: number
|
|
||||||
capabilities: string[]
|
|
||||||
lastError: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AS name lookup ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const AS_NAMES: Record<number, string> = {
|
|
||||||
8359: "МТС / Tele2",
|
|
||||||
13238: "Яндекс",
|
|
||||||
12389: "Ростелеком",
|
|
||||||
24940: "Hetzner",
|
|
||||||
6777: "AMS-IX",
|
|
||||||
1299: "Telia",
|
|
||||||
65001: "iBGP internal",
|
|
||||||
}
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SESSIONS: BgpSession[] = [
|
const SESSIONS: BgpSession[] = [
|
||||||
@@ -240,13 +210,6 @@ function TypeBadge({ type }: { type: BgpType }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CapChip({ cap }: { cap: string }) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
|
|
||||||
{cap}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtNum(n: number) {
|
function fmtNum(n: number) {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||||
@@ -254,108 +217,6 @@ function fmtNum(n: number) {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
|
|
||||||
const max = Math.max(rx, 1)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
|
||||||
{[
|
|
||||||
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
|
||||||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
|
||||||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
|
||||||
].map(r => (
|
|
||||||
<div key={r.label} className="flex items-center gap-2">
|
|
||||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
|
||||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
||||||
<div className={cn("h-full rounded-full", r.color)}
|
|
||||||
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── RSC snippet ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function rscSnippet(s: BgpSession) {
|
|
||||||
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── session expanded row ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function SessionDetail({ s }: { s: BgpSession }) {
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
function copy() {
|
|
||||||
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 1800)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
||||||
{[
|
|
||||||
{ label: "Router ID", value: s.routerId },
|
|
||||||
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
|
|
||||||
{ label: "AFI/SAFI", value: s.afi },
|
|
||||||
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
|
|
||||||
].map(({ label, value }) => (
|
|
||||||
<div key={label}>
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
|
||||||
<p className="text-xs font-mono font-medium">{value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* prefix bars */}
|
|
||||||
{s.state === "Established" && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
|
|
||||||
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* capabilities */}
|
|
||||||
{s.capabilities.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* last error */}
|
|
||||||
{s.lastError && (
|
|
||||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
|
|
||||||
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
|
||||||
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* rsc export */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
|
|
||||||
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
|
|
||||||
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
|
|
||||||
{rscSnippet(s)}
|
|
||||||
</pre>
|
|
||||||
<button onClick={copy}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
|
|
||||||
copied
|
|
||||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
|
||||||
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
|
|
||||||
)}>
|
|
||||||
<ClipboardCopyIcon className="size-3" />
|
|
||||||
{copied ? "Скопировано" : "Копировать"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendBgpSession {
|
interface BackendBgpSession {
|
||||||
@@ -397,158 +258,81 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
|||||||
|
|
||||||
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
|
const BGP_FILTER_ACCESSORS = {
|
||||||
{ value: "all", label: "Все" },
|
state: (s: BgpSession) => s.state,
|
||||||
{ value: "Established", label: "Established" },
|
type: (s: BgpSession) => s.type,
|
||||||
{ value: "Active", label: "Active" },
|
afi: (s: BgpSession) => s.afi,
|
||||||
{ value: "Idle", label: "Idle" },
|
}
|
||||||
{ value: "OpenSent", label: "OpenSent" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
|
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
const filtered = useMemo(() => sessions.filter(s => {
|
const filtered = useMemo(() => {
|
||||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
const base = sessions.filter((s) => {
|
||||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||||
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
|
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||||
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
|
if (
|
||||||
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
|
q &&
|
||||||
return true
|
!s.peerIp.includes(q) &&
|
||||||
}), [sessions, q, stateFilter, typeFilter])
|
!s.description.toLowerCase().includes(q) &&
|
||||||
|
!s.serverLabel.includes(q) &&
|
||||||
|
!String(s.remoteAs).includes(q) &&
|
||||||
|
!(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return applyReuiFilters(base, advancedFilters, BGP_FILTER_ACCESSORS)
|
||||||
|
}, [sessions, q, stateFilter, typeFilter, advancedFilters])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* filter bar */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{/* search */}
|
|
||||||
<div className="relative">
|
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none z-10" />
|
|
||||||
<Input
|
|
||||||
value={search} onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="IP, AS, описание…"
|
|
||||||
className="h-8 pl-8 pr-8 w-52 text-xs"
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button onClick={() => setSearch("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground z-10">
|
|
||||||
<XIcon className="size-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* state filter */}
|
|
||||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
|
||||||
{STATE_FILTERS.map(f => (
|
|
||||||
<button key={f.value} onClick={() => setStateFilter(f.value)}
|
|
||||||
className={cn(
|
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
|
|
||||||
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
|
||||||
)}>
|
|
||||||
{f.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* type filter */}
|
|
||||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
|
||||||
{(["all", "eBGP", "iBGP"] as const).map(t => (
|
|
||||||
<button key={t} onClick={() => setTypeFilter(t)}
|
|
||||||
className={cn(
|
|
||||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
|
||||||
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
|
||||||
)}>
|
|
||||||
{t === "all" ? "Все типы" : t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
|
||||||
{filtered.length} из {sessions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* table */}
|
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<DataPageToolbar
|
||||||
<table className="w-full text-xs">
|
segmented={{
|
||||||
<thead>
|
value: stateFilter,
|
||||||
<tr className="border-b bg-muted/40">
|
onChange: setStateFilter,
|
||||||
<th className="w-8" />
|
options: [
|
||||||
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
|
{ value: "all", label: "Все", count: sessions.length },
|
||||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||||
))}
|
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||||
</tr>
|
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||||
</thead>
|
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||||
<tbody className="divide-y divide-border/60">
|
],
|
||||||
{filtered.map(s => {
|
}}
|
||||||
const isOpen = expandedId === s.id
|
filters={advancedFilters}
|
||||||
return (
|
onFiltersChange={setAdvancedFilters}
|
||||||
<Fragment key={s.id}>
|
filterFields={BGP_FILTER_FIELDS}
|
||||||
<tr
|
search={search}
|
||||||
onClick={() => setExpandedId(isOpen ? null : s.id)}
|
onSearchChange={setSearch}
|
||||||
className={cn(
|
searchPlaceholder="IP, AS, описание…"
|
||||||
"cursor-pointer transition-colors",
|
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||||
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
|
actions={
|
||||||
)}>
|
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||||
<td className="pl-3 py-2.5">
|
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
||||||
{isOpen
|
<button
|
||||||
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
key={t}
|
||||||
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
|
type="button"
|
||||||
</td>
|
onClick={() => setTypeFilter(t)}
|
||||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
|
className={cn(
|
||||||
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
|
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||||
<td className="px-3 py-2.5 font-mono">
|
typeFilter === t
|
||||||
<div className="flex items-center gap-1.5">
|
? "bg-background text-foreground shadow-sm"
|
||||||
<span>AS{s.remoteAs}</span>
|
: "text-muted-foreground hover:text-foreground",
|
||||||
{AS_NAMES[s.remoteAs] && (
|
)}
|
||||||
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
|
>
|
||||||
)}
|
{t === "all" ? "Все типы" : t}
|
||||||
</div>
|
</button>
|
||||||
</td>
|
))}
|
||||||
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
|
</div>
|
||||||
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
|
}
|
||||||
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
|
/>
|
||||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
|
<BgpSessionsDataGrid sessions={filtered} />
|
||||||
{s.uptime ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
|
||||||
{s.prefixesRx > 0
|
|
||||||
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
|
|
||||||
: <span className="text-muted-foreground">—</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
|
||||||
{s.prefixesTx > 0
|
|
||||||
? <span className="text-[var(--chart-tx)]">{fmtNum(s.prefixesTx)}</span>
|
|
||||||
: <span className="text-muted-foreground">—</span>}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{isOpen && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={10} className="p-0">
|
|
||||||
<SessionDetail s={s} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
|
||||||
Нет сессий по заданным фильтрам
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -826,8 +610,8 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
|||||||
export default function BgpPage() {
|
export default function BgpPage() {
|
||||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||||
|
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|||||||
+1045
-270
File diff suppressed because it is too large
Load Diff
@@ -289,8 +289,8 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||||
@@ -306,9 +306,10 @@ export default function DashboardPage() {
|
|||||||
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||||
|
|
||||||
const probeServerCatalog = useMemo(() => {
|
const probeServerCatalog = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
if (!isLive) return mockServers
|
if (!isLive) return mockServers
|
||||||
return liveServersResolved ?? []
|
return liveServersResolved ?? []
|
||||||
}, [isLive, liveServersResolved])
|
}, [prefsHydrated, isLive, liveServersResolved])
|
||||||
|
|
||||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
@@ -523,13 +524,15 @@ export default function DashboardPage() {
|
|||||||
}, [mockDashEpoch])
|
}, [mockDashEpoch])
|
||||||
|
|
||||||
const activeProbesTable = useMemo(() => {
|
const activeProbesTable = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return []
|
||||||
if (!isLive) return mockActiveProbes
|
if (!isLive) return mockActiveProbes
|
||||||
if (liveProbes === null && probesLoading) return []
|
if (liveProbes === null && probesLoading) return []
|
||||||
if (liveProbes === null) return []
|
if (liveProbes === null) return []
|
||||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
}, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||||
|
|
||||||
const probesSubtitle = useMemo(() => {
|
const probesSubtitle = useMemo(() => {
|
||||||
|
if (!prefsHydrated) return "Загрузка…"
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
const starred = mockActiveProbes.length
|
const starred = mockActiveProbes.length
|
||||||
return starred > 0
|
return starred > 0
|
||||||
@@ -541,10 +544,13 @@ export default function DashboardPage() {
|
|||||||
return n > 0
|
return n > 0
|
||||||
? `${n} на дашборде · последний час (API)`
|
? `${n} на дашборде · последний час (API)`
|
||||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
}, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||||
|
|
||||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||||
const serverStatusModel = useMemo(() => {
|
const serverStatusModel = useMemo(() => {
|
||||||
|
if (!prefsHydrated) {
|
||||||
|
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||||
|
}
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
return {
|
return {
|
||||||
kind: "mock" as const,
|
kind: "mock" as const,
|
||||||
@@ -571,9 +577,12 @@ export default function DashboardPage() {
|
|||||||
servers,
|
servers,
|
||||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||||
}
|
}
|
||||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
}, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError])
|
||||||
|
|
||||||
const latencyChartBlock = useMemo(() => {
|
const latencyChartBlock = useMemo(() => {
|
||||||
|
if (!prefsHydrated) {
|
||||||
|
return { kind: "loading" as const }
|
||||||
|
}
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
return {
|
return {
|
||||||
kind: "mock" as const,
|
kind: "mock" as const,
|
||||||
@@ -612,7 +621,7 @@ export default function DashboardPage() {
|
|||||||
subtitle:
|
subtitle:
|
||||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||||
}
|
}
|
||||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
|
||||||
|
|
||||||
const dashboardKpi = useMemo(() => {
|
const dashboardKpi = useMemo(() => {
|
||||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
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 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 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) {
|
if (!isLive) {
|
||||||
const totalSrv = mockServers.length
|
const totalSrv = mockServers.length
|
||||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||||
@@ -664,7 +691,8 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingBlock = probesLoading && liveProbes === null
|
const liveDataPending = liveServersResolved === null || liveProbes === null
|
||||||
|
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
|
||||||
const srvList = liveServersResolved ?? []
|
const srvList = liveServersResolved ?? []
|
||||||
const totalSrv = srvList.length
|
const totalSrv = srvList.length
|
||||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||||
@@ -730,7 +758,7 @@ export default function DashboardPage() {
|
|||||||
sparkColor: "var(--chart-5)",
|
sparkColor: "var(--chart-5)",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
}, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|||||||
+378
-104
@@ -3,6 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -24,13 +25,15 @@ import {
|
|||||||
type SchedulerRunRowDto,
|
type SchedulerRunRowDto,
|
||||||
type UptimeSettingsDto,
|
type UptimeSettingsDto,
|
||||||
} from "@/lib/scheduler-settings"
|
} from "@/lib/scheduler-settings"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
parseSchedulerRunSnapshot,
|
parseSchedulerRunSnapshot,
|
||||||
type AlertEngineRuleDiagSnapshot,
|
type AlertEngineRuleDiagSnapshot,
|
||||||
type AlertEngineRunSnapshot,
|
type AlertEngineRunSnapshot,
|
||||||
type GreBgpSnapshotRunSnapshot,
|
type GreBgpSnapshotRunSnapshot,
|
||||||
type InternetPathRunSnapshot,
|
type InternetPathRunSnapshot,
|
||||||
|
type CertificatesRenewRunSnapshot,
|
||||||
|
type BackupsRunSnapshot,
|
||||||
type PingRunSnapshot,
|
type PingRunSnapshot,
|
||||||
type ResourcesRunSnapshot,
|
type ResourcesRunSnapshot,
|
||||||
type SchedulerRunSnapshot,
|
type SchedulerRunSnapshot,
|
||||||
@@ -56,26 +59,25 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
function extractApiError(reason: unknown): string {
|
||||||
return (
|
if (reason instanceof ApiClientError) return reason.message
|
||||||
<button
|
if (reason instanceof Error) return reason.message
|
||||||
type="button"
|
return String(reason)
|
||||||
role="switch"
|
}
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
||||||
className={cn(
|
return result.status === "fulfilled" ? result.value : null
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
}
|
||||||
checked ? "bg-primary" : "bg-input",
|
|
||||||
)}
|
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
||||||
>
|
const errors: string[] = []
|
||||||
<span
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
className={cn(
|
const result = results[i]
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
if (result.status === "rejected") {
|
||||||
checked ? "translate-x-4" : "translate-x-0",
|
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
|
||||||
)}
|
}
|
||||||
/>
|
}
|
||||||
</button>
|
return errors
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtMs(ms: number): string {
|
function fmtMs(ms: number): string {
|
||||||
@@ -464,6 +466,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</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") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||||
@@ -660,9 +729,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() {
|
export default function DataCollectionPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||||
@@ -683,6 +784,9 @@ export default function DataCollectionPage() {
|
|||||||
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
||||||
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
||||||
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
|
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 [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
||||||
@@ -714,32 +818,79 @@ export default function DataCollectionPage() {
|
|||||||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||||
: "?limit=80"
|
: "?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/traffic/settings"),
|
||||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||||
apiFetch<CollectorSettingsDto>("/api/internet-path/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}`),
|
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||||
])
|
])
|
||||||
setTrafficCollector(traffic)
|
|
||||||
setServersApiCollector(serversApi)
|
const loadErrors = collectSettledErrors(
|
||||||
setUptimeCollector(uptime)
|
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
|
||||||
setInternetPathCollector(internetPath)
|
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
|
||||||
setSchedulerRuns(runsRes.runs ?? [])
|
)
|
||||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
if (loadErrors.length > 0) {
|
||||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
setCollectorError(loadErrors.join("; "))
|
||||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
}
|
||||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
|
||||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
const traffic = readSettled(trafficRes)
|
||||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
if (traffic) {
|
||||||
setDraftTrafficEnabled(!!traffic.enabled)
|
setTrafficCollector(traffic)
|
||||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
setDraftTrafficEnabled(!!traffic.enabled)
|
||||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
}
|
||||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
|
||||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
const serversApi = readSettled(serversApiRes)
|
||||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
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) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -747,6 +898,138 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
}, [apiFetch, isLive, runFilterJobKey])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setTrafficCollector(null)
|
setTrafficCollector(null)
|
||||||
@@ -775,20 +1058,26 @@ export default function DataCollectionPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledJobsCount = useMemo(() => {
|
const enabledJobsCount = useMemo(() => {
|
||||||
|
const jobs = uptimeCollector?.scheduler?.jobs
|
||||||
|
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||||
|
|
||||||
let n = draftTrafficEnabled ? 1 : 0
|
let n = draftTrafficEnabled ? 1 : 0
|
||||||
if (draftServersApiEnabled) n += 1
|
if (draftServersApiEnabled) n += 1
|
||||||
if (draftResourcesEnabled) n += 1
|
if (draftResourcesEnabled) n += 1
|
||||||
if (draftPingEnabled) n += 1
|
if (draftPingEnabled) n += 1
|
||||||
if (draftSpeedEnabled) n += 1
|
if (draftSpeedEnabled) n += 1
|
||||||
if (draftInternetPathEnabled) n += 1
|
if (draftInternetPathEnabled) n += 1
|
||||||
|
if (draftCertRenewEnabled) n += 1
|
||||||
return n
|
return n
|
||||||
}, [
|
}, [
|
||||||
|
draftCertRenewEnabled,
|
||||||
draftInternetPathEnabled,
|
draftInternetPathEnabled,
|
||||||
draftPingEnabled,
|
draftPingEnabled,
|
||||||
draftResourcesEnabled,
|
draftResourcesEnabled,
|
||||||
draftServersApiEnabled,
|
draftServersApiEnabled,
|
||||||
draftSpeedEnabled,
|
draftSpeedEnabled,
|
||||||
draftTrafficEnabled,
|
draftTrafficEnabled,
|
||||||
|
uptimeCollector?.scheduler?.jobs,
|
||||||
])
|
])
|
||||||
|
|
||||||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||||||
@@ -805,7 +1094,9 @@ export default function DataCollectionPage() {
|
|||||||
{
|
{
|
||||||
label: "Включено задач",
|
label: "Включено задач",
|
||||||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||||||
sub: "По переключателям на этой странице (до сохранения)",
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
|
? "По сохранённым задачам планировщика"
|
||||||
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -872,7 +1163,18 @@ export default function DataCollectionPage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
||||||
@@ -917,7 +1219,7 @@ export default function DataCollectionPage() {
|
|||||||
<CardHeader className="border-b border-border pb-4">
|
<CardHeader className="border-b border-border pb-4">
|
||||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
|
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pb-0">
|
<CardContent className="px-0 pb-0">
|
||||||
@@ -936,20 +1238,22 @@ export default function DataCollectionPage() {
|
|||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||||
const j = schedulerJobsByKey[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
|
const en = fixedSchedule
|
||||||
? Boolean(j?.enabled ?? true)
|
? Boolean(j?.enabled ?? true)
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
? draftTrafficEnabled
|
? draftTrafficEnabled
|
||||||
: jobKey === "servers_rest_ping"
|
: jobKey === "servers_rest_ping"
|
||||||
? draftServersApiEnabled
|
? draftServersApiEnabled
|
||||||
: jobKey === "uptime_resources"
|
: jobKey === "uptime_resources"
|
||||||
? draftResourcesEnabled
|
? draftResourcesEnabled
|
||||||
: jobKey === "uptime_ping"
|
: jobKey === "uptime_ping"
|
||||||
? draftPingEnabled
|
? draftPingEnabled
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? draftSpeedEnabled
|
? draftSpeedEnabled
|
||||||
: draftInternetPathEnabled
|
: jobKey === "certificates_renew"
|
||||||
|
? draftCertRenewEnabled
|
||||||
|
: draftInternetPathEnabled
|
||||||
const iv = fixedSchedule
|
const iv = fixedSchedule
|
||||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -962,7 +1266,9 @@ export default function DataCollectionPage() {
|
|||||||
? uptimeIntervalDraft
|
? uptimeIntervalDraft
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? uptimeSpeedIntervalDraft
|
? uptimeSpeedIntervalDraft
|
||||||
: internetPathIntervalDraft
|
: jobKey === "certificates_renew"
|
||||||
|
? certRenewIntervalDraft
|
||||||
|
: internetPathIntervalDraft
|
||||||
const setIv = fixedSchedule
|
const setIv = fixedSchedule
|
||||||
? () => {}
|
? () => {}
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -975,7 +1281,9 @@ export default function DataCollectionPage() {
|
|||||||
? setUptimeIntervalDraft
|
? setUptimeIntervalDraft
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? setUptimeSpeedIntervalDraft
|
? setUptimeSpeedIntervalDraft
|
||||||
: setInternetPathIntervalDraft
|
: jobKey === "certificates_renew"
|
||||||
|
? setCertRenewIntervalDraft
|
||||||
|
: setInternetPathIntervalDraft
|
||||||
const defSec = fixedSchedule
|
const defSec = fixedSchedule
|
||||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||||
: jobKey === "traffic"
|
: jobKey === "traffic"
|
||||||
@@ -988,7 +1296,9 @@ export default function DataCollectionPage() {
|
|||||||
? 15
|
? 15
|
||||||
: jobKey === "uptime_speed"
|
: jobKey === "uptime_speed"
|
||||||
? 60
|
? 60
|
||||||
: 300
|
: jobKey === "certificates_renew"
|
||||||
|
? 21600
|
||||||
|
: 300
|
||||||
return (
|
return (
|
||||||
<tr key={jobKey} className="hover:bg-muted/40">
|
<tr key={jobKey} className="hover:bg-muted/40">
|
||||||
<td className="px-5 py-3 align-top">
|
<td className="px-5 py-3 align-top">
|
||||||
@@ -1000,16 +1310,12 @@ export default function DataCollectionPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-center align-top">
|
<td className="px-3 py-3 text-center align-top">
|
||||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||||
<Toggle
|
<FormToggle
|
||||||
checked={en}
|
checked={en}
|
||||||
|
disabled={fixedSchedule || schedulerSaveBusy}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
if (fixedSchedule) return
|
if (fixedSchedule || schedulerSaveBusy) return
|
||||||
if (jobKey === "traffic") setDraftTrafficEnabled(v)
|
void handleJobEnabledChange(jobKey, 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)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -1111,6 +1417,15 @@ export default function DataCollectionPage() {
|
|||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
|
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
|
||||||
@@ -1122,48 +1437,7 @@ export default function DataCollectionPage() {
|
|||||||
setSchedulerSaveBusy(true)
|
setSchedulerSaveBusy(true)
|
||||||
setCollectorError(null)
|
setCollectorError(null)
|
||||||
try {
|
try {
|
||||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
await persistSchedulerDrafts()
|
||||||
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 loadCollectors()
|
await loadCollectors()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { domains as mockDomains } from "@/lib/data"
|
import { domains as mockDomains } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function DomainsPage() {
|
export default function DomainsPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
@@ -28,7 +31,9 @@ export default function DomainsPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||||
</>
|
</>
|
||||||
@@ -57,6 +62,7 @@ export default function DomainsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по домену…"
|
searchPlaceholder="Поиск по домену…"
|
||||||
searchKeys={["domain", "asn", "filter"]}
|
searchKeys={["domain", "asn", "filter"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -109,6 +115,16 @@ export default function DomainsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт доменов"
|
||||||
|
description="Загрузите CSV или JSON со списком доменов"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-18
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
import { useMemo, useState, useCallback, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
@@ -185,7 +186,7 @@ function generateCombinedRouterOSConfig(
|
|||||||
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`# RouterLists — конфиг фильтрации`,
|
`# MikrotikManager — конфиг фильтрации`,
|
||||||
`# Дата : ${now}`,
|
`# Дата : ${now}`,
|
||||||
`# Серверов : ${withRules.length}`,
|
`# Серверов : ${withRules.length}`,
|
||||||
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
|
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
|
||||||
@@ -271,7 +272,7 @@ function generateCombinedRouterOSConfig(
|
|||||||
|
|
||||||
lines.push(`/routing filter rule add \\`)
|
lines.push(`/routing filter rule add \\`)
|
||||||
lines.push(` chain=bgp-in \\`)
|
lines.push(` chain=bgp-in \\`)
|
||||||
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
lines.push(` comment="MikrotikManager: ${server.name}" \\`)
|
||||||
lines.push(` rule="`)
|
lines.push(` rule="`)
|
||||||
lines.push(branches)
|
lines.push(branches)
|
||||||
lines.push(` "`)
|
lines.push(` "`)
|
||||||
@@ -2062,22 +2063,23 @@ export default function FiltersPage() {
|
|||||||
|
|
||||||
{currentRules.length === 0 ? (
|
{currentRules.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
<EmptyState
|
||||||
<NetworkIcon className="size-8 text-muted-foreground/20" />
|
icon={<NetworkIcon className="size-4" />}
|
||||||
<div>
|
title="Нет правил фильтрации"
|
||||||
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
|
description={
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
? "Добавьте правило: BGP community → GRE-шлюз"
|
||||||
? "Добавьте правило: BGP community → GRE-шлюз"
|
: "Сначала добавьте GRE-туннели для этого сервера"
|
||||||
: "Сначала добавьте GRE-туннели для этого сервера"}
|
}
|
||||||
</p>
|
action={
|
||||||
</div>
|
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
|
||||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
|
<Button size="sm" onClick={openCreate}>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<PlusIcon className="size-4" />Добавить правило
|
||||||
<PlusIcon className="size-4" />Добавить правило
|
</Button>
|
||||||
</Button>
|
) : undefined
|
||||||
)}
|
}
|
||||||
</div>
|
className="py-20"
|
||||||
|
/>
|
||||||
) : filteredRules.length === 0 ? (
|
) : filteredRules.length === 0 ? (
|
||||||
/* no search results */
|
/* no search results */
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -341,22 +342,6 @@ function fmtHits(n: number): string {
|
|||||||
return String(n)
|
return String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" role="switch" aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
||||||
checked ? "bg-primary" : "bg-input",
|
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0",
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionBadge({ action }: { action: string }) {
|
function ActionBadge({ action }: { action: string }) {
|
||||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||||
return (
|
return (
|
||||||
@@ -375,29 +360,6 @@ function ChainBadge({ chain }: { chain: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NativeSelect({ value, onChange, children, className }: {
|
function NativeSelect({ value, onChange, children, className }: {
|
||||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||||
}) {
|
}) {
|
||||||
@@ -417,7 +379,7 @@ function NativeSelect({ value, onChange, children, className }: {
|
|||||||
|
|
||||||
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# RouterLists Firewall Export`)
|
lines.push(`# MikrotikManager Firewall Export`)
|
||||||
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
|
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
|
||||||
lines.push(`# Правил: ${rules.length}`)
|
lines.push(`# Правил: ${rules.length}`)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
@@ -507,67 +469,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Цепочка и действие</SectionTitle>
|
<SectionTitle>Цепочка и действие</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Цепочка" required>
|
<FormField label="Цепочка" required>
|
||||||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||||||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Действие" required>
|
<FormField label="Действие" required>
|
||||||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matching */}
|
{/* Matching */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Условие совпадения</SectionTitle>
|
<SectionTitle>Условие совпадения</SectionTitle>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
||||||
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
||||||
<option key={p} value={p}>{p}</option>
|
<option key={p} value={p}>{p}</option>
|
||||||
)}
|
)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
|
<FormField label="Src-address / Address-list" hint="IP, CIDR или имя address-list">
|
||||||
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
<Input className="font-mono h-8" placeholder="10.0.0.0/8"
|
||||||
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-address / Address-list">
|
<FormField label="Dst-address / Address-list">
|
||||||
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
||||||
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Src-port" hint="TCP/UDP, напр. 1024-65535">
|
<FormField label="Src-port" hint="TCP/UDP, напр. 1024-65535">
|
||||||
<Input className="font-mono h-8" placeholder="—"
|
<Input className="font-mono h-8" placeholder="—"
|
||||||
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst-port">
|
<FormField label="Dst-port">
|
||||||
<Input className="font-mono h-8" placeholder="443"
|
<Input className="font-mono h-8" placeholder="443"
|
||||||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="In-interface" hint="Входящий интерфейс">
|
<FormField label="In-interface" hint="Входящий интерфейс">
|
||||||
<Input className="font-mono h-8" placeholder="wan-msk"
|
<Input className="font-mono h-8" placeholder="wan-msk"
|
||||||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" placeholder="lan"
|
<Input className="font-mono h-8" placeholder="lan"
|
||||||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Connection-state" hint="Через запятую: new, established, related, invalid">
|
<FormField label="Connection-state" hint="Через запятую: new, established, related, invalid">
|
||||||
<Input className="font-mono h-8" placeholder="new,established"
|
<Input className="font-mono h-8" placeholder="new,established"
|
||||||
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log + Comment */}
|
{/* Log + Comment */}
|
||||||
@@ -578,18 +540,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Log</p>
|
<p className="text-sm font-medium">Log</p>
|
||||||
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
|
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
|
||||||
</div>
|
</div>
|
||||||
{form.log && (
|
{form.log && (
|
||||||
<Field label="Log-prefix" hint="Метка в логе, например FW-DROP">
|
<FormField label="Log-prefix" hint="Метка в логе, например FW-DROP">
|
||||||
<Input className="font-mono h-8" placeholder="FW-RULE"
|
<Input className="font-mono h-8" placeholder="FW-RULE"
|
||||||
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-8" placeholder="Описание правила"
|
<Input className="h-8" placeholder="Описание правила"
|
||||||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enabled */}
|
{/* Enabled */}
|
||||||
@@ -598,7 +560,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
|||||||
<p className="text-sm font-medium">Правило включено</p>
|
<p className="text-sm font-medium">Правило включено</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CLI preview */}
|
{/* CLI preview */}
|
||||||
@@ -1039,14 +1001,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Название</SectionTitle>
|
<SectionTitle>Название</SectionTitle>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Название сценария" required>
|
<FormField label="Название сценария" required>
|
||||||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||||||
value={name} onChange={e => setName(e.target.value)} />
|
value={name} onChange={e => setName(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Описание">
|
<FormField label="Описание">
|
||||||
<Input className="h-8" placeholder="Краткое описание"
|
<Input className="h-8" placeholder="Краткое описание"
|
||||||
value={desc} onChange={e => setDesc(e.target.value)} />
|
value={desc} onChange={e => setDesc(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1054,7 +1016,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
<Field label="Направление / цепочка">
|
<FormField label="Направление / цепочка">
|
||||||
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
<NativeSelect value={pkt.chain} onChange={v => setP("chain", v)}>
|
||||||
<optgroup label="Полный маршрут">
|
<optgroup label="Полный маршрут">
|
||||||
<option value="forward">forward — транзит</option>
|
<option value="forward">forward — транзит</option>
|
||||||
@@ -1067,40 +1029,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
.map(c => <option key={c} value={c}>{c}</option>)}
|
.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||||||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Conn-state">
|
<FormField label="Conn-state">
|
||||||
<Input className="font-mono h-8" value={pkt.connState}
|
<Input className="font-mono h-8" value={pkt.connState}
|
||||||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Src IP">
|
<FormField label="Src IP">
|
||||||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||||||
onChange={e => setP("srcAddr", e.target.value)} />
|
onChange={e => setP("srcAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst IP">
|
<FormField label="Dst IP">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||||||
onChange={e => setP("dstAddr", e.target.value)} />
|
onChange={e => setP("dstAddr", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst Port">
|
<FormField label="Dst Port">
|
||||||
<Input className="font-mono h-8" value={pkt.dstPort}
|
<Input className="font-mono h-8" value={pkt.dstPort}
|
||||||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="In-interface">
|
<FormField label="In-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.inIface}
|
<Input className="font-mono h-8" value={pkt.inIface}
|
||||||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Out-interface">
|
<FormField label="Out-interface">
|
||||||
<Input className="font-mono h-8" value={pkt.outIface}
|
<Input className="font-mono h-8" value={pkt.outIface}
|
||||||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Dst addr-list">
|
<FormField label="Dst addr-list">
|
||||||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||||||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1170,7 +1132,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Toggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
|
<FormToggle checked={addForm.enabled} onChange={v => setAF("enabled", v)} />
|
||||||
<span className="text-xs text-muted-foreground">Включено</span>
|
<span className="text-xs text-muted-foreground">Включено</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
||||||
@@ -1783,7 +1745,7 @@ function RulesTable({ rules, onToggle, onEdit }: {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
<FormToggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
+50
-101
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -54,7 +55,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
|||||||
const srv = serverById[t.serverId]
|
const srv = serverById[t.serverId]
|
||||||
|
|
||||||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||||||
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
|
lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
|
|
||||||
// GRE interface
|
// GRE interface
|
||||||
@@ -138,58 +139,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
|
|
||||||
>
|
|
||||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-1">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
||||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((o) => (
|
|
||||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
||||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
@@ -272,8 +221,8 @@ type PageTab = "tunnels" | "pools"
|
|||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
export default function GrePage() {
|
export default function GrePage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
@@ -838,51 +787,51 @@ export default function GrePage() {
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сервер (MikroTik)" required>
|
<FormField label="Сервер (MikroTik)" required>
|
||||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать сервер…</option>
|
<option value="" disabled>Выбрать сервер…</option>
|
||||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<span className="text-sm font-medium">Включён</span>
|
||||||
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
<FormToggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Эндпоинты</SectionTitle>
|
<SectionTitle>Эндпоинты</SectionTitle>
|
||||||
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
<FormField label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
||||||
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
<FormField label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||||||
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Внутренний IP</SectionTitle>
|
<SectionTitle>Внутренний IP</SectionTitle>
|
||||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать пул…</option>
|
<option value="" disabled>Выбрать пул…</option>
|
||||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Локальный IP" required hint="/ip address на этом конце">
|
<FormField label="Локальный IP" required hint="/ip address на этом конце">
|
||||||
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Удалённый IP" required hint="/ip address на другом конце">
|
<FormField label="Удалённый IP" required hint="/ip address на другом конце">
|
||||||
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -893,12 +842,12 @@ export default function GrePage() {
|
|||||||
<p className="text-sm font-medium">Включить IPsec</p>
|
<p className="text-sm font-medium">Включить IPsec</p>
|
||||||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tForm.ipsecEnabled && (
|
{tForm.ipsecEnabled && (
|
||||||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||||||
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
<FormField label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||||||
@@ -907,38 +856,38 @@ export default function GrePage() {
|
|||||||
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="IKE-версия">
|
<FormField label="IKE-версия">
|
||||||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||||||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Шифрование">
|
<FormField label="Шифрование">
|
||||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Хеш-алгоритм">
|
<FormField label="Хеш-алгоритм">
|
||||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
<FormField label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
||||||
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<div className="flex items-center justify-between pt-6">
|
<div className="flex items-center justify-between pt-6">
|
||||||
<span className="text-sm font-medium">PFS</span>
|
<span className="text-sm font-medium">PFS</span>
|
||||||
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
<FormToggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -955,23 +904,23 @@ export default function GrePage() {
|
|||||||
{tForm.showAdvanced && (
|
{tForm.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="MTU" hint="По умолч. 1476">
|
<FormField label="MTU" hint="По умолч. 1476">
|
||||||
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Keepalive, с" hint="0 = откл.">
|
<FormField label="Keepalive, с" hint="0 = откл.">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Попытки">
|
<FormField label="Попытки">
|
||||||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="DSCP">
|
<FormField label="DSCP">
|
||||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="inherit">inherit</option>
|
<option value="inherit">inherit</option>
|
||||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</FormField>
|
||||||
{[
|
{[
|
||||||
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||||
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
||||||
@@ -981,7 +930,7 @@ export default function GrePage() {
|
|||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
<FormToggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,18 +955,18 @@ export default function GrePage() {
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры пула</SectionTitle>
|
<SectionTitle>Параметры пула</SectionTitle>
|
||||||
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
<FormField label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
||||||
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Назначение / Комментарий">
|
<FormField label="Назначение / Комментарий">
|
||||||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||||||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||||||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||||||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
export default function IpRangesPage() {
|
export default function IpRangesPage() {
|
||||||
const { mode } = useDataSource()
|
const { mode } = useDataSource()
|
||||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||||
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
|
|
||||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
|
||||||
const useEvoCatalog = mode === "live" && enabled
|
const useEvoCatalog = mode === "live" && enabled
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
@@ -29,7 +31,9 @@ export default function IpRangesPage() {
|
|||||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
|
<UploadIcon className="size-4" />Импорт
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||||
</>
|
</>
|
||||||
@@ -58,6 +62,7 @@ export default function IpRangesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
|
isLoading={useEvoCatalog && loading && !snapshot}
|
||||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -108,6 +113,16 @@ export default function IpRangesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FileImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
title="Импорт IP-диапазонов"
|
||||||
|
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||||
|
accept=".csv,.json,text/csv,application/json"
|
||||||
|
onImport={async (files) => {
|
||||||
|
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
import { DataSourceProvider } from "@/lib/data-source"
|
import { DataSourceProvider } from "@/lib/data-source"
|
||||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<ReleaseNotesModal />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</EvoBGPProvider>
|
</EvoBGPProvider>
|
||||||
</DataSourceProvider>
|
</DataSourceProvider>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function MainLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 p-6">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||||
|
<Skeleton className="h-96 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -777,12 +777,12 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export default function NetworkMapPage() {
|
export default function NetworkMapPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const useLiveData = mode === "live" && backendStatus === true
|
const useLiveData = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [mapServers, setMapServers] = useState<Server[]>(mockServers)
|
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>(mockGreTunnels)
|
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||||
|
|||||||
@@ -1227,8 +1227,8 @@ export default function OspfPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||||
|
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
|
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -1291,6 +1291,16 @@ export default function OspfPage() {
|
|||||||
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
||||||
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
||||||
}
|
}
|
||||||
|
if (isLive) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
neighbors: [],
|
||||||
|
graphNodes: [],
|
||||||
|
graphEdges: [],
|
||||||
|
routerIds: {},
|
||||||
|
bfdSessions: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
items: MOCK_ITEMS,
|
items: MOCK_ITEMS,
|
||||||
neighbors: MOCK_NEIGHBORS,
|
neighbors: MOCK_NEIGHBORS,
|
||||||
@@ -1318,6 +1328,7 @@ export default function OspfPage() {
|
|||||||
}
|
}
|
||||||
return [...seen.values()]
|
return [...seen.values()]
|
||||||
}
|
}
|
||||||
|
if (isLive) return []
|
||||||
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
|
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
|
||||||
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
|
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" }
|
const siteCountry: Record<string, string> = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -353,17 +354,6 @@ function NativeSelect({ value, onChange, children, className }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
|
||||||
checked ? "bg-primary" : "bg-muted-foreground/30")}>
|
|
||||||
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0.5")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OptionLabel({ children }: { children: React.ReactNode }) {
|
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||||
}
|
}
|
||||||
@@ -593,7 +583,7 @@ function ScheduleTab({
|
|||||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||||
!rule.enabled && "opacity-50",
|
!rule.enabled && "opacity-50",
|
||||||
)}>
|
)}>
|
||||||
<Toggle checked={rule.enabled}
|
<FormToggle checked={rule.enabled}
|
||||||
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
|
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
|
||||||
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
|
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
|
||||||
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
||||||
@@ -729,10 +719,8 @@ export default function ProbesPage() {
|
|||||||
|
|
||||||
const allServers = useMemo(() => {
|
const allServers = useMemo(() => {
|
||||||
if (!isLive) return servers
|
if (!isLive) return servers
|
||||||
if (liveServers.length > 0) return liveServers
|
return liveServers
|
||||||
if (liveLoad === "error") return servers
|
}, [isLive, liveServers])
|
||||||
return []
|
|
||||||
}, [isLive, liveServers, liveLoad])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
@@ -1117,7 +1105,7 @@ export default function ProbesPage() {
|
|||||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -148,32 +149,6 @@ const emptyForm = (): RouteForm => ({
|
|||||||
endpoints: [newEndpoint()],
|
endpoints: [newEndpoint()],
|
||||||
})
|
})
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string
|
|
||||||
hint?: string
|
|
||||||
required?: boolean
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteGroupRows({
|
function RouteGroupRows({
|
||||||
group, expanded, onToggle, onEdit, onDelete,
|
group, expanded, onToggle, onEdit, onDelete,
|
||||||
}: {
|
}: {
|
||||||
@@ -370,9 +345,9 @@ function RouteSheet({
|
|||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||||
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -396,26 +371,26 @@ function RouteSheet({
|
|||||||
|
|
||||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||||
|
|
||||||
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||||
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Distance (приоритет)">
|
<FormField label="Distance (приоритет)">
|
||||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Check Gateway">
|
<FormField label="Check Gateway">
|
||||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Scope">
|
<FormField label="Scope">
|
||||||
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="T.Scope">
|
<FormField label="T.Scope">
|
||||||
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||||
@@ -467,10 +442,10 @@ function RouteSheet({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Параметры</SectionTitle>
|
<SectionTitle>Параметры</SectionTitle>
|
||||||
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
|
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -498,12 +473,12 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecursiveRoutesPage() {
|
export default function RecursiveRoutesPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [servers, setServers] = useState<Server[]>(mockServers)
|
const [servers, setServers] = useState<Server[]>([])
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
|
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { PageHeader } from "@/components/page-header"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
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 { readReleaseManifest } from "@/lib/release-manifest"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ExternalLinkIcon } from "lucide-react"
|
import { ExternalLinkIcon } from "lucide-react"
|
||||||
@@ -24,7 +29,7 @@ export default function ReleasesPage() {
|
|||||||
const version = getAppVersion()
|
const version = getAppVersion()
|
||||||
const isProdBuild = !version.endsWith("-dev")
|
const isProdBuild = !version.endsWith("-dev")
|
||||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
||||||
const commits = manifest.commits
|
const commits = getReleaseCommitPreview(manifest.commits)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -50,7 +55,7 @@ export default function ReleasesPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Текущая версия</CardTitle>
|
<CardTitle>Текущая версия</CardTitle>
|
||||||
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
|
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -68,7 +73,7 @@ export default function ReleasesPage() {
|
|||||||
<CardTitle>Сводка коммитов</CardTitle>
|
<CardTitle>Сводка коммитов</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{commits.length > 0
|
{commits.length > 0
|
||||||
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
|
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -84,7 +89,7 @@ export default function ReleasesPage() {
|
|||||||
<span className="font-mono">{commit.shortSha}</span>
|
<span className="font-mono">{commit.shortSha}</span>
|
||||||
<span>{commit.author}</span>
|
<span>{commit.author}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm">{commit.subject}</p>
|
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -245,17 +246,6 @@ function LossChip({ loss }: { loss: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
|
||||||
checked ? "bg-primary" : "bg-input")}>
|
|
||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||||
return (
|
return (
|
||||||
<Input type="number" value={value} min={min} max={max}
|
<Input type="number" value={value} min={min} max={max}
|
||||||
@@ -1167,7 +1157,7 @@ export default function RouteOptimizerPage() {
|
|||||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm">Применять автоматически</span>
|
<span className="text-sm">Применять автоматически</span>
|
||||||
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||||||
</div>
|
</div>
|
||||||
{settings.autoApply && (
|
{settings.autoApply && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+159
-472
@@ -1,8 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
|
||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||||
|
import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields"
|
||||||
import { servers as initialServers } from "@/lib/data"
|
import { servers as initialServers } from "@/lib/data"
|
||||||
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
import type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||||
@@ -30,56 +35,25 @@ import {
|
|||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
Stepper,
|
||||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
StepperContent,
|
||||||
} from "@/components/ui/dropdown-menu"
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperNav,
|
||||||
|
StepperPanel,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
} from "@/components/reui/stepper"
|
||||||
import {
|
import {
|
||||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
EyeIcon, EyeOffIcon,
|
||||||
ChevronRightIcon, ChevronDownIcon,
|
ChevronRightIcon, ChevronDownIcon,
|
||||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
ShieldIcon, WifiIcon,
|
||||||
HomeIcon, ServerIcon, NetworkIcon,
|
HomeIcon, ServerIcon, NetworkIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── RouterOS version utilities ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */
|
|
||||||
function rosVer(os: string): number {
|
|
||||||
const m = os.match(/(\d+)\.(\d+)/)
|
|
||||||
if (!m) return 0
|
|
||||||
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RosFeature { name: string; minVer: number; label: string; desc: string }
|
|
||||||
|
|
||||||
const ROS_FEATURES: RosFeature[] = [
|
|
||||||
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
|
||||||
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
|
||||||
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
|
||||||
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
|
||||||
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
|
||||||
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
|
||||||
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
|
||||||
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
|
||||||
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
|
||||||
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function RosBadge({ os }: { os: string }) {
|
|
||||||
const v = rosVer(os)
|
|
||||||
const cls = v >= 715
|
|
||||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
|
||||||
: v >= 710
|
|
||||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
|
||||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
|
||||||
return (
|
|
||||||
<span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>
|
|
||||||
{os}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Countries ───────────────────────────────────────────────────────────────
|
// ─── Countries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
@@ -97,90 +71,8 @@ const COUNTRIES = [
|
|||||||
{ code: "NO", label: "Норвегия" },
|
{ code: "NO", label: "Норвегия" },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ─── Type config ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ServerType, string> = {
|
|
||||||
"jump-host": "JumpHost",
|
|
||||||
"exit-node": "Exit Node",
|
|
||||||
"home-router": "Home Router",
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_STYLES: Record<ServerType, string> = {
|
|
||||||
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
|
||||||
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
|
||||||
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_ICONS: Record<ServerType, React.ReactNode> = {
|
|
||||||
"jump-host": <ServerIcon className="size-3 mr-1" />,
|
|
||||||
"exit-node": <NetworkIcon className="size-3 mr-1" />,
|
|
||||||
"home-router": <HomeIcon className="size-3 mr-1" />,
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: ServerType }) {
|
|
||||||
return (
|
|
||||||
<span className={cn(
|
|
||||||
"inline-flex items-center text-xs font-medium border rounded px-2 py-0.5",
|
|
||||||
TYPE_STYLES[type],
|
|
||||||
)}>
|
|
||||||
{TYPE_ICONS[type]}{TYPE_LABELS[type]}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared small components ──────────────────────────────────────────────────
|
// ─── Shared small components ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function Field({ label, hint, required, children }: {
|
|
||||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" role="switch" aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
|
|
||||||
checked ? "bg-primary" : "bg-input")}>
|
|
||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 py-0.5">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
|
||||||
<div className="flex-1 h-px bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
|
||||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((o) => (
|
|
||||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
|
||||||
className={cn("px-3 py-1 text-sm rounded transition-colors",
|
|
||||||
value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Country field ────────────────────────────────────────────────────────────
|
// ─── Country field ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
@@ -267,30 +159,30 @@ function WanUplinkEditor({ wans, onChange }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Field label="Имя" required>
|
<FormField label="Имя" required>
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
|
||||||
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Интерфейс">
|
<FormField label="Интерфейс">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
||||||
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Провайдер (ISP)">
|
<FormField label="Провайдер (ISP)">
|
||||||
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
||||||
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Внешний IP">
|
<FormField label="Внешний IP">
|
||||||
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
||||||
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↓ Макс. Мбит">
|
<FormField label="↓ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="↑ Макс. Мбит">
|
<FormField label="↑ Макс. Мбит">
|
||||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||||
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -321,17 +213,18 @@ type SheetMode = "add" | "edit"
|
|||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
export default function ServersPage() {
|
export default function ServersPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<Server[]>(initialServers)
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
const [_backendOk, setBackendOk] = useState(false)
|
const [_backendOk, setBackendOk] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [sheetStep, setSheetStep] = useState(1)
|
||||||
const [form, setForm] = useState<FormState>(defaultForm)
|
const [form, setForm] = useState<FormState>(defaultForm)
|
||||||
const [testState, setTestState] = useState<TestState>("idle")
|
const [testState, setTestState] = useState<TestState>("idle")
|
||||||
const [testMsg, setTestMsg] = useState("")
|
const [testMsg, setTestMsg] = useState("")
|
||||||
@@ -358,7 +251,7 @@ export default function ServersPage() {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setBackendOk(false)
|
setBackendOk(false)
|
||||||
setServerList(initialServers)
|
setServerList([])
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mode, backendUrl])
|
}, [mode, backendUrl])
|
||||||
@@ -368,6 +261,7 @@ export default function ServersPage() {
|
|||||||
function openAdd() {
|
function openAdd() {
|
||||||
setSheetMode("add"); setEditingId(null)
|
setSheetMode("add"); setEditingId(null)
|
||||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||||
|
setSheetStep(1)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +275,7 @@ export default function ServersPage() {
|
|||||||
lanSubnet: s.lanSubnet ?? "",
|
lanSubnet: s.lanSubnet ?? "",
|
||||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||||
})
|
})
|
||||||
setTestState("idle"); setTestMsg(""); setOpen(true)
|
setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)
|
||||||
|
|
||||||
// Fetch full server details (including credentials) from backend
|
// Fetch full server details (including credentials) from backend
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
@@ -549,13 +443,14 @@ export default function ServersPage() {
|
|||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
})
|
})
|
||||||
}, [serverList, search, typeFilter])
|
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
|
}, [serverList, search, typeFilter, advancedFilters])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -615,272 +510,34 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card className="overflow-hidden py-0 gap-0">
|
||||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
segmented={{
|
||||||
{tabs.map(tab => (
|
value: typeFilter,
|
||||||
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
|
onChange: setTypeFilter,
|
||||||
className={cn(
|
options: tabs.map((tab) => ({
|
||||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
value: tab.value,
|
||||||
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
label: tab.label,
|
||||||
)}>
|
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||||
{tab.label}
|
})),
|
||||||
<span className="text-xs tabular-nums opacity-60">
|
}}
|
||||||
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
|
filters={advancedFilters}
|
||||||
</span>
|
onFiltersChange={setAdvancedFilters}
|
||||||
</button>
|
filterFields={SERVER_FILTER_FIELDS}
|
||||||
))}
|
search={search}
|
||||||
</div>
|
onSearchChange={setSearch}
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
searchPlaceholder="Поиск по имени, хосту…"
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
countLabel={`${filtered.length} серверов`}
|
||||||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
/>
|
||||||
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
|
<ServersDataGrid
|
||||||
</div>
|
servers={filtered}
|
||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
|
isLive={isLive}
|
||||||
</div>
|
pollingIds={pollingIds}
|
||||||
|
onPoll={handlePoll}
|
||||||
<div className="overflow-x-auto">
|
onEdit={openEdit}
|
||||||
<table className="w-full text-sm">
|
onDelete={handleDelete}
|
||||||
<thead>
|
onToggleStatus={handleToggleStatus}
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
/>
|
||||||
<th className="text-left font-medium px-5 py-3">Имя / Хост</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Тип</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Модель</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">RouterOS</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Площадка</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">WAN / LAN</th>
|
|
||||||
<th className="text-right font-medium px-4 py-3">Задержка</th>
|
|
||||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
|
||||||
<th className="w-10 px-3 py-3" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{filtered.map(s => {
|
|
||||||
const isExpanded = expandedId === s.id
|
|
||||||
const ver = rosVer(s.os)
|
|
||||||
return (
|
|
||||||
<Fragment key={s.id}>
|
|
||||||
<tr
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-muted/40 transition-colors cursor-pointer",
|
|
||||||
isExpanded && "bg-muted/30",
|
|
||||||
)}
|
|
||||||
onClick={() => setExpandedId(prev => prev === s.id ? null : s.id)}
|
|
||||||
>
|
|
||||||
{/* Expand chevron + name */}
|
|
||||||
<td className="px-5 py-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
{isExpanded
|
|
||||||
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
|
||||||
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium truncate">{s.name}</p>
|
|
||||||
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
|
||||||
{s.ipv6Address && (
|
|
||||||
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
|
||||||
{s.ipv6Address}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3"><TypeBadge type={s.type} /></td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{s.model}</td>
|
|
||||||
<td className="px-4 py-3"><RosBadge os={s.os} /></td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Flag code={s.country} />
|
|
||||||
<span className="font-medium">{s.site}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{/* WAN / LAN column */}
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
{s.type === "home-router" && s.wanUplinks?.length ? (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{s.wanUplinks.map(w => (
|
|
||||||
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
|
||||||
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
|
||||||
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
|
||||||
<span className="text-muted-foreground">{w.isp}</span>
|
|
||||||
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{s.lanSubnet && (
|
|
||||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
|
||||||
LAN {s.lanSubnet}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
|
||||||
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
|
||||||
<ShieldIcon className="size-3" />
|
|
||||||
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{s.rpkiEnabled && (
|
|
||||||
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
|
||||||
)}
|
|
||||||
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className={cn("px-4 py-3 font-mono text-right text-sm",
|
|
||||||
s.latency == null ? "text-muted-foreground"
|
|
||||||
: s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "")}>
|
|
||||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3"><StatusBadge status={s.status} /></td>
|
|
||||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger render={
|
|
||||||
<Button variant="ghost" size="icon" className="size-7">
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
} />
|
|
||||||
<DropdownMenuContent side="bottom" align="end">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>{s.name}</DropdownMenuLabel>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => window.open(`https://${s.host}`, "_blank")}>
|
|
||||||
<ExternalLinkIcon className="size-3.5" />Открыть WebFig
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => openEdit(s)}>
|
|
||||||
<PencilIcon className="size-3.5" />Редактировать
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{isLive && (
|
|
||||||
<DropdownMenuItem onClick={() => handlePoll(s.id)} disabled={pollingIds.has(s.id)}>
|
|
||||||
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
|
|
||||||
{pollingIds.has(s.id) ? "Опрос…" : "Опросить"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={() => handleToggleStatus(s.id)}>
|
|
||||||
<PowerIcon className="size-3.5" />
|
|
||||||
{s.status === "offline" ? "Включить" : "Отключить"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem variant="destructive" onClick={() => handleDelete(s.id)}>
|
|
||||||
<Trash2Icon className="size-3.5" />Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* ── Expandable detail row ── */}
|
|
||||||
{isExpanded && (
|
|
||||||
<tr className="bg-muted/20">
|
|
||||||
<td colSpan={9} className="px-8 py-5 border-b border-border/50">
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
|
|
||||||
{/* Snapshot / live data */}
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
|
||||||
{s.model && s.model !== "—" && (
|
|
||||||
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
|
||||||
)}
|
|
||||||
{s.uptime && (
|
|
||||||
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
|
||||||
)}
|
|
||||||
{s.cpuLoad != null && (
|
|
||||||
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
|
||||||
)}
|
|
||||||
{s.asn && (
|
|
||||||
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
|
||||||
)}
|
|
||||||
{s.ipv6Address && (
|
|
||||||
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
|
||||||
)}
|
|
||||||
{s.vrfNames?.map(v => (
|
|
||||||
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
|
||||||
))}
|
|
||||||
{s.comment && (
|
|
||||||
<span className="text-muted-foreground italic">{s.comment}</span>
|
|
||||||
)}
|
|
||||||
{s.polledAt && (
|
|
||||||
<span className="text-muted-foreground/50 text-[11px]">
|
|
||||||
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!s.polledAt && (
|
|
||||||
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLive && (
|
|
||||||
<Button
|
|
||||||
variant="outline" size="sm"
|
|
||||||
className="h-7 gap-1.5 text-xs shrink-0"
|
|
||||||
disabled={pollingIds.has(s.id)}
|
|
||||||
onClick={e => { e.stopPropagation(); handlePoll(s.id) }}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className={cn("size-3.5", pollingIds.has(s.id) && "animate-spin")} />
|
|
||||||
{pollingIds.has(s.id) ? "Опрос…" : "Опросить сейчас"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature matrix */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
|
||||||
Возможности RouterOS
|
|
||||||
</p>
|
|
||||||
<RosBadge os={s.os} />
|
|
||||||
<span className="text-[11px] text-muted-foreground">
|
|
||||||
{ver >= 715
|
|
||||||
? "✓ Актуальная версия — все ключевые фичи доступны"
|
|
||||||
: ver >= 710
|
|
||||||
? "⚠ Рекомендуется обновление до 7.15+"
|
|
||||||
: s.os !== "—"
|
|
||||||
? "✗ Устаревшая версия — требуется обновление"
|
|
||||||
: "Нет данных — нажмите «Опросить сейчас»"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
|
||||||
{ROS_FEATURES.map(f => {
|
|
||||||
const ok = ver >= f.minVer
|
|
||||||
return (
|
|
||||||
<div key={f.name} className={cn(
|
|
||||||
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
|
||||||
ok
|
|
||||||
? "border-emerald-500/25 bg-emerald-500/5"
|
|
||||||
: "border-border/40 bg-background/40 opacity-60",
|
|
||||||
)}>
|
|
||||||
{ok
|
|
||||||
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
|
||||||
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className={cn(
|
|
||||||
"text-xs font-medium leading-tight truncate",
|
|
||||||
ok ? "text-foreground" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{f.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
|
||||||
{f.label} · {f.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -893,18 +550,45 @@ export default function ServersPage() {
|
|||||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||||
|
<StepperNav className="mb-5">
|
||||||
|
<StepperItem step={1}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>1</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={2}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>2</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={3}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>3</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">API</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
<StepperSeparator />
|
||||||
|
</StepperItem>
|
||||||
|
<StepperItem step={4}>
|
||||||
|
<StepperTrigger>
|
||||||
|
<StepperIndicator>4</StepperIndicator>
|
||||||
|
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||||
|
</StepperTrigger>
|
||||||
|
</StepperItem>
|
||||||
|
</StepperNav>
|
||||||
|
<StepperPanel className="flex-1 overflow-y-auto">
|
||||||
|
<StepperContent value={1} className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* 1. Основные */}
|
<FormField label="Имя сервера" required hint="Например home-msk-01">
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Основные</SectionTitle>
|
|
||||||
|
|
||||||
<Field label="Имя сервера" required hint="Например home-msk-01">
|
|
||||||
<Input className="font-mono" placeholder="home-msk-01"
|
<Input className="font-mono" placeholder="home-msk-01"
|
||||||
value={form.name} onChange={e => set("name", e.target.value)} />
|
value={form.name} onChange={e => set("name", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Тип узла" required>
|
<FormField label="Тип узла" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={v => set("type", v)}
|
onChange={v => set("type", v)}
|
||||||
@@ -914,24 +598,24 @@ export default function ServersPage() {
|
|||||||
{ value: "exit-node", label: "Exit Node" },
|
{ value: "exit-node", label: "Exit Node" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Площадка" required hint="MSK, SPB, FRA…">
|
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
|
||||||
<Input className="font-mono uppercase" placeholder="MSK"
|
<Input className="font-mono uppercase" placeholder="MSK"
|
||||||
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
||||||
</Field>
|
</FormField>
|
||||||
{!isHomeRouter && (
|
{!isHomeRouter && (
|
||||||
<Field label="ASN" hint="Например AS65001">
|
<FormField label="ASN" hint="Например AS65001">
|
||||||
<Input className="font-mono" placeholder="AS65001"
|
<Input className="font-mono" placeholder="AS65001"
|
||||||
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
{isHomeRouter && (
|
{isHomeRouter && (
|
||||||
<Field label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
|
||||||
<Input className="font-mono" placeholder="192.168.10.0/24"
|
<Input className="font-mono" placeholder="192.168.10.0/24"
|
||||||
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -939,45 +623,43 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Включён</span>
|
<span className="text-sm font-medium">Включён</span>
|
||||||
<Toggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={2} className="flex flex-col gap-4">
|
||||||
{/* 2. WAN-аплинки (только для home-router) */}
|
|
||||||
{isHomeRouter && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||||
|
{!isHomeRouter ? (
|
||||||
|
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||||
|
) : (
|
||||||
<WanUplinkEditor
|
<WanUplinkEditor
|
||||||
wans={form.wanUplinks}
|
wans={form.wanUplinks}
|
||||||
onChange={wans => set("wanUplinks", wans)}
|
onChange={wans => set("wanUplinks", wans)}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</StepperContent>
|
||||||
|
<StepperContent value={3} className="flex flex-col gap-4">
|
||||||
{/* 3. Подключение (API) */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||||
|
|
||||||
<Field label="Хост / IP-адрес" required
|
<FormField label="Хост / IP-адрес" required
|
||||||
hint={isHomeRouter
|
hint={isHomeRouter
|
||||||
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
||||||
: "Внешний или управляющий IP-адрес роутера"}>
|
: "Внешний или управляющий IP-адрес роутера"}>
|
||||||
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
||||||
value={form.host} onChange={e => set("host", e.target.value)} />
|
value={form.host} onChange={e => set("host", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={form.proto}
|
value={form.proto}
|
||||||
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
||||||
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Порт" hint="443 / 80">
|
<FormField label="Порт" hint="443 / 80">
|
||||||
<Input className="font-mono" placeholder="443"
|
<Input className="font-mono" placeholder="443"
|
||||||
value={form.port} onChange={e => set("port", e.target.value)} />
|
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -988,13 +670,13 @@ export default function ServersPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Путь API">
|
<FormField label="Путь API">
|
||||||
<Input className="font-mono" placeholder="/rest"
|
<Input className="font-mono" placeholder="/rest"
|
||||||
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||||
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
||||||
@@ -1008,12 +690,12 @@ export default function ServersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||||
<Input className="font-mono" placeholder="api-user"
|
<Input className="font-mono" placeholder="api-user"
|
||||||
value={form.username} onChange={e => set("username", e.target.value)} />
|
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Пароль" required>
|
<FormField label="Пароль" required>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input type={form.showPassword ? "text" : "password"}
|
<Input type={form.showPassword ? "text" : "password"}
|
||||||
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
||||||
@@ -1024,7 +706,7 @@ export default function ServersPage() {
|
|||||||
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||||
@@ -1046,10 +728,8 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StepperContent>
|
||||||
|
<StepperContent value={4} className="flex flex-col gap-4">
|
||||||
{/* 4. Дополнительно */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||||
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||||
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||||
@@ -1059,34 +739,41 @@ export default function ServersPage() {
|
|||||||
{form.showAdvanced && (
|
{form.showAdvanced && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="SSH-порт">
|
<FormField label="SSH-порт">
|
||||||
<Input type="number" className="font-mono" value={form.sshPort}
|
<Input type="number" className="font-mono" value={form.sshPort}
|
||||||
onChange={e => set("sshPort", Number(e.target.value))} />
|
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Winbox-порт">
|
<FormField label="Winbox-порт">
|
||||||
<Input type="number" className="font-mono" value={form.winboxPort}
|
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||||
onChange={e => set("winboxPort", Number(e.target.value))} />
|
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Таймаут соединения, с">
|
<FormField label="Таймаут соединения, с">
|
||||||
<Input type="number" className="font-mono" value={form.timeout}
|
<Input type="number" className="font-mono" value={form.timeout}
|
||||||
onChange={e => set("timeout", Number(e.target.value))} />
|
onChange={e => set("timeout", Number(e.target.value))} />
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Комментарий">
|
<FormField label="Комментарий">
|
||||||
<Input placeholder="Описание или заметка" value={form.comment}
|
<Input placeholder="Описание или заметка" value={form.comment}
|
||||||
onChange={e => set("comment", e.target.value)} />
|
onChange={e => set("comment", e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StepperContent>
|
||||||
|
</StepperPanel>
|
||||||
</div>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1" onClick={handleSave}>
|
{sheetStep > 1 && (
|
||||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
</Button>
|
)}
|
||||||
|
{sheetStep < 4 ? (
|
||||||
|
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="ml-auto" onClick={handleSave}>
|
||||||
|
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle } from "@/components/form-kit"
|
||||||
|
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||||
import {
|
import {
|
||||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||||
} from "@/components/ui/card"
|
} from "@/components/ui/card"
|
||||||
@@ -162,17 +164,6 @@ function makeApiFetch(backendUrl: string) {
|
|||||||
|
|
||||||
// ─── small components ─────────────────────────────────────────────────────────
|
// ─── small components ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => onChange(!checked)}
|
|
||||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
|
||||||
checked ? "bg-primary" : "bg-input")}>
|
|
||||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0")} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||||
@@ -413,24 +404,24 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
{tab === "profile" && (
|
{tab === "profile" && (
|
||||||
<div className="px-5 py-5 flex flex-col gap-4">
|
<div className="px-5 py-5 flex flex-col gap-4">
|
||||||
|
|
||||||
<Field label="Полное имя" error={errors.name}>
|
<FormField label="Полное имя" error={errors.name}>
|
||||||
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
<Input value={form.name} onChange={e => setField("name", e.target.value)}
|
||||||
placeholder="Иван Иванов" className="h-9" />
|
placeholder="Иван Иванов" className="h-9" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Логин" error={errors.login}>
|
<FormField label="Логин" error={errors.login}>
|
||||||
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
<Input value={form.login} onChange={e => setField("login", e.target.value)}
|
||||||
placeholder="i.ivanov" className="h-9 font-mono" />
|
placeholder="i.ivanov" className="h-9 font-mono" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Email" error={errors.email}>
|
<FormField label="Email" error={errors.email}>
|
||||||
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
<Input value={form.email} onChange={e => setField("email", e.target.value)}
|
||||||
placeholder="[email protected]" type="email" className="h-9" />
|
placeholder="[email protected]" type="email" className="h-9" />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Роль">
|
<FormField label="Роль">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
||||||
<button key={r} type="button" onClick={() => setRole(r)}
|
<button key={r} type="button" onClick={() => setRole(r)}
|
||||||
@@ -453,18 +444,18 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
? "Управление инфраструктурой согласно выданным правам"
|
? "Управление инфраструктурой согласно выданным правам"
|
||||||
: "Только просмотр согласно выданным правам"}
|
: "Только просмотр согласно выданным правам"}
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Field label="Статус учётной записи">
|
<FormField label="Статус учётной записи">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Toggle checked={form.active} onChange={v => setField("active", v)} />
|
<FormToggle checked={form.active} onChange={v => setField("active", v)} />
|
||||||
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
<span className={cn("text-xs font-medium", form.active ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||||
{form.active ? "Активна" : "Заблокирована"}
|
{form.active ? "Активна" : "Заблокирована"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -660,7 +651,7 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* active toggle */}
|
{/* active toggle */}
|
||||||
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||||
|
|
||||||
{/* delete */}
|
{/* delete */}
|
||||||
<button onClick={() => removeSubUser(su.id)}
|
<button onClick={() => removeSubUser(su.id)}
|
||||||
@@ -775,22 +766,6 @@ function UserSheet({ open, user, onSave, onClose }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helper ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-medium">{label}</label>
|
|
||||||
{children}
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-destructive flex items-center gap-1">
|
|
||||||
<AlertCircleIcon className="size-3" />{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── delete confirm ───────────────────────────────────────────────────────────
|
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DatabaseRestoreConfirm({
|
function DatabaseRestoreConfirm({
|
||||||
@@ -900,7 +875,7 @@ export default function SettingsPage() {
|
|||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||||
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
|
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
const [notifEmail, setNotifEmail] = useState(true)
|
const [notifEmail, setNotifEmail] = useState(true)
|
||||||
@@ -1031,7 +1006,6 @@ export default function SettingsPage() {
|
|||||||
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
||||||
toast.success("База приложения восстановлена")
|
toast.success("База приложения восстановлена")
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1230,30 +1204,16 @@ export default function SettingsPage() {
|
|||||||
description="Полностью заменяет текущую базу SQLite"
|
description="Полностью заменяет текущую базу SQLite"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<Input
|
<Button
|
||||||
ref={dbRestoreInputRef}
|
variant="outline"
|
||||||
type="file"
|
size="sm"
|
||||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
className="h-8"
|
||||||
className="hidden"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||||
onChange={(e) => {
|
onClick={() => setDbRestoreDialogOpen(true)}
|
||||||
const file = e.target.files?.[0] ?? null
|
>
|
||||||
if (!file) return
|
<UploadIcon className="size-4" />
|
||||||
setDbRestoreFile(file)
|
Выбрать файл
|
||||||
}}
|
</Button>
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
|
||||||
onClick={() => dbRestoreInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<UploadIcon className="size-4" />
|
|
||||||
Выбрать файл
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{dbRestoreFile && (
|
{dbRestoreFile && (
|
||||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1384,7 +1344,7 @@ export default function SettingsPage() {
|
|||||||
label="Подставлять данные EvoBGP"
|
label="Подставлять данные EvoBGP"
|
||||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||||
>
|
>
|
||||||
<Toggle
|
<FormToggle
|
||||||
checked={evoEnabledDraft}
|
checked={evoEnabledDraft}
|
||||||
onChange={(v) => setEvoEnabledDraft(v)}
|
onChange={(v) => setEvoEnabledDraft(v)}
|
||||||
/>
|
/>
|
||||||
@@ -1496,15 +1456,15 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
<SettingRow label="Email" description="Отправка уведомлений на [email protected]">
|
||||||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="[email protected]" /></div>}
|
||||||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||||||
<Toggle checked={notifSlack} onChange={setNotifSlack} />
|
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
{notifSlack && <div className="py-3"><Input className="text-sm h-8 font-mono" placeholder="https://hooks.slack.com/…" /></div>}
|
||||||
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
<SettingRow label="Webhook" description="POST-запрос на произвольный endpoint">
|
||||||
<Toggle checked={notifWh} onChange={setNotifWh} />
|
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1513,16 +1473,16 @@ export default function SettingsPage() {
|
|||||||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||||
<Toggle checked={notifDegr} onChange={setNotifDegr} />
|
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Узел ушёл offline">
|
<SettingRow label="Узел ушёл offline">
|
||||||
<Toggle checked={notifOffline} onChange={setNotifOffline} />
|
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Падение BGP-сессии">
|
<SettingRow label="Падение BGP-сессии">
|
||||||
<Toggle checked={notifBgp} onChange={setNotifBgp} />
|
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||||
<Toggle checked={notifBackup} onChange={setNotifBackup} />
|
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1741,7 +1701,7 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||||
description="TOTP / Authenticator app для всех администраторов">
|
description="TOTP / Authenticator app для всех администраторов">
|
||||||
<Toggle checked={mfa} onChange={setMfa} />
|
<FormToggle checked={mfa} onChange={setMfa} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||||||
@@ -1763,7 +1723,7 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="divide-y px-5">
|
<CardContent className="divide-y px-5">
|
||||||
<SettingRow label="Расширенный журнал аудита"
|
<SettingRow label="Расширенный журнал аудита"
|
||||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||||
<Toggle checked={auditLog} onChange={setAuditLog} />
|
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1853,7 +1813,6 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (dbRestoreBusy) return
|
if (dbRestoreBusy) return
|
||||||
setDbRestoreFile(null)
|
setDbRestoreFile(null)
|
||||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1864,6 +1823,19 @@ export default function SettingsPage() {
|
|||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FileImportDialog
|
||||||
|
open={dbRestoreDialogOpen}
|
||||||
|
onOpenChange={setDbRestoreDialogOpen}
|
||||||
|
title="Восстановление базы данных"
|
||||||
|
description="Выберите файл SQLite (.db) — текущая база будет полностью заменена"
|
||||||
|
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||||
|
onImport={async (files) => {
|
||||||
|
const file = files[0]
|
||||||
|
if (!file) return
|
||||||
|
setDbRestoreFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,8 +427,8 @@ interface BackendServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalPage() {
|
export default function TerminalPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
|
|
||||||
// Server list state
|
// Server list state
|
||||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||||
|
|||||||
@@ -758,8 +758,8 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function TrafficPage() {
|
export default function TrafficPage() {
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
const isLive = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
||||||
|
|||||||
+34
-96
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -212,21 +213,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
|
|||||||
|
|
||||||
// ── shared components ──────────────────────────────────────────────────────────
|
// ── shared components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={() => onChange(!checked)}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
|
||||||
checked ? "bg-primary" : "bg-input",
|
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
|
||||||
checked ? "translate-x-4" : "translate-x-0",
|
|
||||||
)} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -267,18 +253,6 @@ function StatChip({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-xs font-medium">
|
|
||||||
{label}
|
|
||||||
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Активный интерфейс RouterOS: не disabled и running */
|
/** Активный интерфейс RouterOS: не disabled и running */
|
||||||
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
||||||
return i.running === true && i.disabled !== true
|
return i.running === true && i.disabled !== true
|
||||||
@@ -323,36 +297,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl<T extends string>({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
value: T
|
|
||||||
onChange: (v: T) => void
|
|
||||||
options: Array<{ value: T; label: string }>
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
|
||||||
{options.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 text-sm rounded transition-colors",
|
|
||||||
value === option.value
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServerPickerCards({
|
function ServerPickerCards({
|
||||||
options,
|
options,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -1293,15 +1237,15 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
// ── page ───────────────────────────────────────────────────────────────────────
|
// ── page ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function UptimePage() {
|
export default function UptimePage() {
|
||||||
const [allServers, setAllServers] = useState<Server[]>(mockServers)
|
|
||||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||||||
const liveApi = mode === "live"
|
const liveApi = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
|
const [allServers, setAllServers] = useState<Server[]>([])
|
||||||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
||||||
const [probes, setProbes] = useState<PingProbe[]>(INIT_PROBES)
|
const [probes, setProbes] = useState<PingProbe[]>([])
|
||||||
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
|
const [resources, setResources] = useState<ServerResource[]>([])
|
||||||
const [isPaused, setIsPaused] = useState(false)
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
||||||
@@ -1334,18 +1278,12 @@ export default function UptimePage() {
|
|||||||
enabled: true,
|
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(() => {
|
useEffect(() => {
|
||||||
if (liveApi) return
|
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])
|
}, [liveApi])
|
||||||
|
|
||||||
const isPausedRef = useRef(isPaused)
|
const isPausedRef = useRef(isPaused)
|
||||||
@@ -2407,7 +2345,7 @@ export default function UptimePage() {
|
|||||||
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{/* enable toggle */}
|
{/* enable toggle */}
|
||||||
<Toggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
<FormToggle checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||||
|
|
||||||
{/* route: src → dst */}
|
{/* route: src → dst */}
|
||||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
@@ -2830,7 +2768,7 @@ export default function UptimePage() {
|
|||||||
!p.enabled && "opacity-40",
|
!p.enabled && "opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
|
||||||
<Toggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
|
||||||
<StatusDot
|
<StatusDot
|
||||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
pulse={p.status === "up" && p.enabled}
|
pulse={p.status === "up" && p.enabled}
|
||||||
@@ -3008,7 +2946,7 @@ export default function UptimePage() {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||||
<Field label="Источник">
|
<FormField label="Источник">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources}
|
options={selectableSources}
|
||||||
selectedId={speedDraft.srcServerId}
|
selectedId={speedDraft.srcServerId}
|
||||||
@@ -3021,9 +2959,9 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextSrc)
|
void loadSpeedInterfaces(nextSrc)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Назначение">
|
<FormField label="Назначение">
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
||||||
selectedId={speedDraft.dstServerId}
|
selectedId={speedDraft.dstServerId}
|
||||||
@@ -3032,28 +2970,28 @@ export default function UptimePage() {
|
|||||||
void loadSpeedInterfaces(nextDst)
|
void loadSpeedInterfaces(nextDst)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника">
|
<FormField label="Интерфейс источника">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.srcInterface}
|
value={speedDraft.srcInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс назначения">
|
<FormField label="Интерфейс назначения">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={speedDraft.dstInterface}
|
value={speedDraft.dstInterface}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
||||||
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
||||||
autoLabel="auto"
|
autoLabel="auto"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="Протокол">
|
<FormField label="Протокол">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.protocol}
|
value={speedDraft.protocol}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
||||||
@@ -3062,8 +3000,8 @@ export default function UptimePage() {
|
|||||||
{ value: "udp", label: "UDP" },
|
{ value: "udp", label: "UDP" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Direction">
|
<FormField label="Direction">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={speedDraft.direction}
|
value={speedDraft.direction}
|
||||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
||||||
@@ -3073,10 +3011,10 @@ export default function UptimePage() {
|
|||||||
{ value: "receive", label: "rx" },
|
{ value: "receive", label: "rx" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
<Field label="Сек">
|
<FormField label="Сек">
|
||||||
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{speedDraft.srcServerId &&
|
{speedDraft.srcServerId &&
|
||||||
@@ -3175,7 +3113,7 @@ export default function UptimePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field
|
<FormField
|
||||||
label="Источник (кто пингует)"
|
label="Источник (кто пингует)"
|
||||||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||||
>
|
>
|
||||||
@@ -3184,9 +3122,9 @@ export default function UptimePage() {
|
|||||||
selectedId={newSrcId}
|
selectedId={newSrcId}
|
||||||
onSelect={(id) => setNewSrcId(id)}
|
onSelect={(id) => setNewSrcId(id)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Интерфейс источника" hint="(необязательно)">
|
<FormField label="Интерфейс источника" hint="(необязательно)">
|
||||||
<InterfacePickerCards
|
<InterfacePickerCards
|
||||||
value={newSrcInterface}
|
value={newSrcInterface}
|
||||||
onChange={setNewSrcInterface}
|
onChange={setNewSrcInterface}
|
||||||
@@ -3194,25 +3132,25 @@ export default function UptimePage() {
|
|||||||
autoLabel="авто (по маршруту)"
|
autoLabel="авто (по маршруту)"
|
||||||
busy={srcInterfacesBusy}
|
busy={srcInterfacesBusy}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Имя пробы">
|
<FormField label="Имя пробы">
|
||||||
<Input className="h-9 text-sm" placeholder="youtube.com"
|
<Input className="h-9 text-sm" placeholder="youtube.com"
|
||||||
value={newName} onChange={e => setNewName(e.target.value)} />
|
value={newName} onChange={e => setNewName(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Целевой IP / хост">
|
<FormField label="Целевой IP / хост">
|
||||||
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
<Input className="h-9 text-sm font-mono" placeholder="142.250.74.110"
|
||||||
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
||||||
</Field>
|
</FormField>
|
||||||
|
|
||||||
<Field label="Связанный фильтр" hint="(необязательно)">
|
<FormField label="Связанный фильтр" hint="(необязательно)">
|
||||||
<LinkedFilterPickerCards
|
<LinkedFilterPickerCards
|
||||||
value={newFilter}
|
value={newFilter}
|
||||||
onChange={setNewFilter}
|
onChange={setNewFilter}
|
||||||
items={filters}
|
items={filters}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { servers } from "@/lib/data"
|
import { servers } from "@/lib/data"
|
||||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
@@ -410,11 +411,12 @@ export default function WireGuardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<EmptyState
|
||||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
icon={<ShieldCheckIcon className="size-4" />}
|
||||||
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
|
title="Нет WireGuard интерфейсов"
|
||||||
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
|
description="Добавьте первый интерфейс или проверьте поиск"
|
||||||
</div>
|
className="border-0 py-16"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((iface) => (
|
filtered.map((iface) => (
|
||||||
<IfaceRow
|
<IfaceRow
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/backup", {
|
||||||
|
method: "GET",
|
||||||
|
forwardRequestBody: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextRequest } from "next/server"
|
||||||
|
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
export const maxDuration = 600
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
|
||||||
|
}
|
||||||
@@ -39,6 +39,15 @@
|
|||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--color-info-foreground: var(--info-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-foreground: var(--success-foreground);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-warning-foreground: var(--warning-foreground);
|
||||||
|
--color-invert: var(--invert);
|
||||||
|
--color-invert-foreground: var(--invert-foreground);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -74,6 +83,15 @@
|
|||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: var(--color-red-800);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-900);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-900);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-900);
|
||||||
|
--invert: var(--color-zinc-900);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
/* Borders + inputs */
|
/* Borders + inputs */
|
||||||
--border: oklch(0.904 0.006 264.0);
|
--border: oklch(0.904 0.006 264.0);
|
||||||
@@ -148,6 +166,15 @@
|
|||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: var(--color-red-600);
|
||||||
|
--info: var(--color-violet-500);
|
||||||
|
--info-foreground: var(--color-violet-600);
|
||||||
|
--success: var(--color-emerald-500);
|
||||||
|
--success-foreground: var(--color-emerald-600);
|
||||||
|
--warning: var(--color-yellow-500);
|
||||||
|
--warning-foreground: var(--color-yellow-600);
|
||||||
|
--invert: var(--color-zinc-700);
|
||||||
|
--invert-foreground: var(--color-zinc-50);
|
||||||
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "RouterLists",
|
title: "MikrotikManager",
|
||||||
description: "MikroTik network management platform",
|
description: "MikroTik network management platform",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
|
storage/backups/
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mmapp/contracts": "1.0.0",
|
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
|
"@mmapp/contracts": "1.0.0",
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
type SqliteHandle = InstanceType<typeof Database>
|
type SqliteHandle = InstanceType<typeof Database>
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
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'))
|
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 (
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -587,12 +655,70 @@ SELECT 1, '', ''
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 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(`
|
sqlite.exec(`
|
||||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
||||||
SELECT 1, NULL
|
SELECT 1, NULL
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
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 })
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||||
|
|||||||
@@ -287,6 +287,70 @@ export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
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 = все одновременно в окне тика. */
|
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||||
export const alertGroups = sqliteTable("alert_groups", {
|
export const alertGroups = sqliteTable("alert_groups", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -505,6 +569,11 @@ export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
|||||||
export type EventRow = typeof events.$inferSelect
|
export type EventRow = typeof events.$inferSelect
|
||||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||||
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$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 AlertGroupRow = typeof alertGroups.$inferSelect
|
||||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
|
|||||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||||
import alertsRoutes from "./routes/alerts.js"
|
import alertsRoutes from "./routes/alerts.js"
|
||||||
import backupsRoutes from "./routes/backups.js"
|
import backupsRoutes from "./routes/backups.js"
|
||||||
|
import certificatesRoutes from "./routes/certificates.js"
|
||||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||||
import eventsRoutes from "./routes/events.js"
|
import eventsRoutes from "./routes/events.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
@@ -26,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
|||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
bodyLimit: 512 * 1024 * 1024,
|
||||||
|
requestTimeout: 10 * 60 * 1000,
|
||||||
logger: {
|
logger: {
|
||||||
transport: {
|
transport: {
|
||||||
target: "pino-pretty",
|
target: "pino-pretty",
|
||||||
@@ -70,6 +73,7 @@ await app.register(schedulerRoutes, { prefix: "/api" })
|
|||||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||||
await app.register(alertsRoutes, { prefix: "/api" })
|
await app.register(alertsRoutes, { prefix: "/api" })
|
||||||
await app.register(backupsRoutes, { prefix: "/api" })
|
await app.register(backupsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||||
await app.register(eventsRoutes, { prefix: "/api" })
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
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 path from "node:path"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-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 { 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"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
type BackupMeta = {
|
import {
|
||||||
id: string
|
deleteBackupRecord,
|
||||||
serverId: string
|
getBackupById,
|
||||||
serverName: string
|
getBackupsDir,
|
||||||
filename: string
|
getBackupScheduleSettings,
|
||||||
sizeBytes: number
|
listBackups,
|
||||||
createdAt: string
|
runBackupForServer,
|
||||||
kind: "manual"
|
updateBackupScheduleSettings,
|
||||||
notes?: string
|
type BackupMeta,
|
||||||
}
|
} from "../services/backup-service.js"
|
||||||
|
|
||||||
type BackupJobStatus = "queued" | "running" | "done" | "failed"
|
type BackupJobStatus = "queued" | "running" | "done" | "failed"
|
||||||
type BackupJob = {
|
type BackupJob = {
|
||||||
@@ -32,36 +31,8 @@ type BackupJob = {
|
|||||||
failures: Array<{ serverId: string; error: string }>
|
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>()
|
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({
|
const CreateBackupBodySchema = z.object({
|
||||||
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
|
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
|
||||||
notes: z.string().max(500).optional(),
|
notes: z.string().max(500).optional(),
|
||||||
@@ -74,43 +45,12 @@ const BackupJobIdParamSchema = z.object({
|
|||||||
jobId: z.string().min(1),
|
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) {
|
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||||
job.status = "running"
|
job.status = "running"
|
||||||
job.startedAt = new Date().toISOString()
|
job.startedAt = new Date().toISOString()
|
||||||
const indexRows = await readIndex()
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
const meta = await runBackupForServer(id, notes)
|
const meta = await runBackupForServer(id, "manual", notes)
|
||||||
indexRows.unshift(meta)
|
|
||||||
job.created.push(meta)
|
job.created.push(meta)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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
|
job.completed += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await writeIndex(indexRows)
|
|
||||||
job.status = "done"
|
job.status = "done"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
appendEvent({
|
appendEvent({
|
||||||
@@ -141,9 +80,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
|
|
||||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/backups", async (_req, reply) => {
|
app.get("/backups", async (_req, reply) => {
|
||||||
const rows = await readIndex()
|
return reply.send(listBackups())
|
||||||
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
})
|
||||||
return reply.send(rows)
|
|
||||||
|
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) => {
|
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) => {
|
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const rows = await readIndex()
|
const hit = getBackupById(req.params.id)
|
||||||
const hit = rows.find((r) => r.id === req.params.id)
|
|
||||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
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)
|
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
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) => {
|
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const rows = await readIndex()
|
const hit = await deleteBackupRecord(req.params.id)
|
||||||
const idx = rows.findIndex((r) => r.id === req.params.id)
|
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||||
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 })
|
|
||||||
return reply.status(204).send()
|
return reply.status(204).send()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,6 +5,10 @@ import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
|||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
import {
|
||||||
|
hasManagedCommentPrefix,
|
||||||
|
managedComment,
|
||||||
|
} from "../managed-markers.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -595,11 +599,11 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const isInBgpIn = (r: RosFilterRule) =>
|
const isInBgpIn = (r: RosFilterRule) =>
|
||||||
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
||||||
const managedComment = `RouterLists: ${server.name || server.host}`
|
const managedCommentValue = managedComment(server.name || server.host)
|
||||||
|
|
||||||
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
||||||
const managedRule = existing.find(
|
const managedRule = existing.find(
|
||||||
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
|
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
||||||
@@ -608,7 +612,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const conflictIds = existing
|
const conflictIds = existing
|
||||||
.filter(r =>
|
.filter(r =>
|
||||||
isInBgpIn(r) &&
|
isInBgpIn(r) &&
|
||||||
!(r.comment ?? "").startsWith("RouterLists:") &&
|
!hasManagedCommentPrefix(r.comment ?? "") &&
|
||||||
/bgp-communities/i.test(r.rule ?? ""),
|
/bgp-communities/i.test(r.rule ?? ""),
|
||||||
)
|
)
|
||||||
.map(r => r[".id"])
|
.map(r => r[".id"])
|
||||||
@@ -641,7 +645,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||||
{
|
{
|
||||||
chain: "bgp-in",
|
chain: "bgp-in",
|
||||||
comment: managedComment,
|
comment: managedCommentValue,
|
||||||
rule: ruleBody,
|
rule: ruleBody,
|
||||||
disabled: "no",
|
disabled: "no",
|
||||||
},
|
},
|
||||||
@@ -650,7 +654,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
} else {
|
} else {
|
||||||
await client.post("/routing/filter/rule/add", {
|
await client.post("/routing/filter/rule/add", {
|
||||||
chain: "bgp-in",
|
chain: "bgp-in",
|
||||||
comment: managedComment,
|
comment: managedCommentValue,
|
||||||
rule: ruleBody,
|
rule: ruleBody,
|
||||||
})
|
})
|
||||||
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { db } from "../db/index.js"
|
|||||||
import { recursiveRoutes, servers } from "../db/schema.js"
|
import { recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
import {
|
||||||
|
hasManagedRecursiveComment,
|
||||||
|
managedRecursiveComment,
|
||||||
|
} from "../managed-markers.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
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.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
|
||||||
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
||||||
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
||||||
comment: route.comment
|
comment: managedRecursiveComment(route.comment),
|
||||||
? `RouterLists:recursive ${route.comment}`
|
|
||||||
: "RouterLists:recursive",
|
|
||||||
disabled: route.disabled ? "true" : "false",
|
disabled: route.disabled ? "true" : "false",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
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) {
|
for (const r of managed) {
|
||||||
if (!r[".id"]) continue
|
if (!r[".id"]) continue
|
||||||
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
filterRules,
|
filterRules,
|
||||||
@@ -16,13 +17,15 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
uptimeProbesTotal,
|
uptimeProbesTotal,
|
||||||
uptimeSpeedProbesTotal,
|
uptimeSpeedProbesTotal,
|
||||||
recursiveRoutesTotal,
|
recursiveRoutesTotal,
|
||||||
] = [
|
certificatesTotal,
|
||||||
db.select().from(servers).all().length,
|
] = await Promise.all([
|
||||||
db.select().from(filterRules).all().length,
|
Promise.resolve(db.select().from(servers).all().length),
|
||||||
db.select().from(uptimeProbes).all().length,
|
Promise.resolve(db.select().from(filterRules).all().length),
|
||||||
db.select().from(uptimeSpeedProbes).all().length,
|
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
||||||
db.select().from(recursiveRoutes).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({
|
return reply.send({
|
||||||
servers: serversTotal,
|
servers: serversTotal,
|
||||||
@@ -31,6 +34,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
uptimeSpeedProbes: uptimeSpeedProbesTotal,
|
uptimeSpeedProbes: uptimeSpeedProbesTotal,
|
||||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||||
recursiveRoutes: recursiveRoutesTotal,
|
recursiveRoutes: recursiveRoutesTotal,
|
||||||
|
certificates: certificatesTotal,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(
|
function rosDelete(
|
||||||
params: MikrotikConnectParams,
|
params: MikrotikConnectParams,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
|
|||||||
throw new Error("RouterOS REST: неожиданное тело ответа")
|
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 ─────────────────────────────────────────────────────────────
|
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MikrotikClient {
|
export class MikrotikClient {
|
||||||
@@ -295,6 +363,10 @@ export class MikrotikClient {
|
|||||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
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> {
|
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||||
return rosDelete(this.params, path, timeoutMs)
|
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)
|
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> {
|
async exportConfigScript(): Promise<string> {
|
||||||
const raw = await this.post<unknown>("/console/export", {}, 30_000)
|
const raw = await this.post<unknown>("/console/export", {}, 30_000)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ import {
|
|||||||
collectInternetPathSnapshotOnce,
|
collectInternetPathSnapshotOnce,
|
||||||
getInternetPathSettings,
|
getInternetPathSettings,
|
||||||
} from "./internet-path-collector.js"
|
} 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 {
|
import {
|
||||||
endSchedulerJob,
|
endSchedulerJob,
|
||||||
isSchedulerJobRunning,
|
isSchedulerJobRunning,
|
||||||
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
|
|||||||
"uptime_speed",
|
"uptime_speed",
|
||||||
"internet_path",
|
"internet_path",
|
||||||
"gre_bgp",
|
"gre_bgp",
|
||||||
|
"certificates_renew",
|
||||||
|
"backups",
|
||||||
"alert_engine",
|
"alert_engine",
|
||||||
] as const
|
] as const
|
||||||
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
|
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
|
||||||
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
case "internet_path":
|
case "internet_path":
|
||||||
snapshot = await collectInternetPathSnapshotOnce()
|
snapshot = await collectInternetPathSnapshotOnce()
|
||||||
break
|
break
|
||||||
|
case "certificates_renew":
|
||||||
|
snapshot = await collectCertificatesRenewOnce()
|
||||||
|
break
|
||||||
|
case "backups":
|
||||||
|
snapshot = await collectScheduledBackupsOnce()
|
||||||
|
break
|
||||||
case "alert_engine": {
|
case "alert_engine": {
|
||||||
const r = await runAlertEngineOnce()
|
const r = await runAlertEngineOnce()
|
||||||
snapshot = {
|
snapshot = {
|
||||||
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
|
|||||||
}, greBgpMs),
|
}, 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
|
const alertMs = 20_000
|
||||||
void executeSchedulerJob("alert_engine").catch(() => {})
|
void executeSchedulerJob("alert_engine").catch(() => {})
|
||||||
timers.set(
|
timers.set(
|
||||||
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
|
|||||||
const uptime = getUptimeSettings()
|
const uptime = getUptimeSettings()
|
||||||
const apiPing = getServersApiPingSettings()
|
const apiPing = getServersApiPingSettings()
|
||||||
const internetPath = getInternetPathSettings()
|
const internetPath = getInternetPathSettings()
|
||||||
|
const certRenew = getCertificateRenewSettings()
|
||||||
|
const backupSchedule = getBackupScheduleSettings()
|
||||||
|
|
||||||
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
||||||
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
||||||
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
|
|||||||
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
||||||
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
|
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
|
||||||
gre_bgp: { enabled: true, intervalSec: 30 },
|
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 },
|
alert_engine: { enabled: true, intervalSec: 20 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
|
|||||||
fatalError?: string
|
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 =
|
export type SchedulerRunSnapshot =
|
||||||
| TrafficRunSnapshot
|
| TrafficRunSnapshot
|
||||||
| ResourcesRunSnapshot
|
| ResourcesRunSnapshot
|
||||||
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
|
|||||||
| ServersRestPingRunSnapshot
|
| ServersRestPingRunSnapshot
|
||||||
| GreBgpSnapshotRunSnapshot
|
| GreBgpSnapshotRunSnapshot
|
||||||
| InternetPathRunSnapshot
|
| InternetPathRunSnapshot
|
||||||
|
| CertificatesRenewRunSnapshot
|
||||||
|
| BackupsRunSnapshot
|
||||||
| AlertEngineRunSnapshot
|
| 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
@@ -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
+3
-1
@@ -21,5 +21,7 @@
|
|||||||
},
|
},
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
"registries": {}
|
"registries": {
|
||||||
|
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const evo = useEvoBGP()
|
const evo = useEvoBGP()
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||||
@@ -142,7 +142,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (mode !== "live" || backendStatus !== true) {
|
if (mode !== "live") {
|
||||||
setLiveCounts(null)
|
setLiveCounts(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,15 +176,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearInterval(id)
|
window.clearInterval(id)
|
||||||
}
|
}
|
||||||
}, [mode, backendStatus, backendUrl])
|
}, [mode, backendUrl])
|
||||||
|
|
||||||
const navGroups = React.useMemo((): NavGroup[] => {
|
const navGroups = React.useMemo((): NavGroup[] => {
|
||||||
function badgeFor(url: string): string | undefined {
|
function badgeFor(url: string): string | undefined {
|
||||||
if (mode !== "live") return mockBadges[url]
|
if (mode !== "live") return mockBadges[url]
|
||||||
if (backendStatus !== true) return undefined
|
|
||||||
|
|
||||||
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
|
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 (!evo.snapshot) return undefined
|
||||||
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
|
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
|
||||||
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.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 === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
|
||||||
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||||
|
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
|
||||||
|
|
||||||
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
|
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
badge: mounted ? badgeFor(it.url) : undefined,
|
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 isDark = (resolvedTheme ?? "dark") === "dark"
|
||||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
||||||
@@ -227,7 +227,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<RouterIcon className="size-4" />
|
<RouterIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
<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>
|
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { Backup } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
DataGridColumnHeader,
|
||||||
|
DataGridContainer,
|
||||||
|
DataGridTable,
|
||||||
|
} from "@/components/reui/data-grid"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
interface BackupsDataGridProps {
|
||||||
|
backups: Backup[]
|
||||||
|
onDownload: (id: string, filename: string) => void
|
||||||
|
onRestore: (backup: Backup) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupsDataGrid({
|
||||||
|
backups,
|
||||||
|
onDownload,
|
||||||
|
onRestore,
|
||||||
|
onDelete,
|
||||||
|
}: BackupsDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<Backup>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "filename",
|
||||||
|
accessorKey: "filename",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Файл" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server",
|
||||||
|
accessorKey: "server",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "size",
|
||||||
|
accessorKey: "size",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "kind",
|
||||||
|
accessorKey: "kind",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const kind = row.original.kind
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded border font-medium",
|
||||||
|
kind === "manual"
|
||||||
|
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||||
|
: "bg-muted text-muted-foreground border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{kind === "auto" ? "авто" : "вручную"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
accessorKey: "notes",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||||
|
{row.original.notes || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
accessorKey: "created",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Создан" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
title="Скачать"
|
||||||
|
onClick={() => onDownload(b.id, b.filename)}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
title="Восстановить"
|
||||||
|
onClick={() => onRestore(b)}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 text-destructive hover:text-destructive"
|
||||||
|
title="Удалить"
|
||||||
|
onClick={() => onDelete(b.id)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onDelete, onDownload, onRestore],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: backups,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<HardDriveIcon className="size-4" />}
|
||||||
|
title="Нет бэкапов"
|
||||||
|
description="Создайте первый бэкап вручную или настройте расписание"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
|
||||||
|
<DataGridContainer>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridContainer>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BackupsDataGrid, type BackupsDataGridProps }
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ClipboardCopyIcon } from "lucide-react"
|
||||||
|
import type { BgpSessionRow } from "@/lib/bgp/types"
|
||||||
|
import { fmtBgpNum, rscBgpSnippet } from "@/lib/bgp/helpers"
|
||||||
|
|
||||||
|
function CapChip({ cap }: { cap: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
|
||||||
|
{cap}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
|
||||||
|
const max = Math.max(rx, 1)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
||||||
|
{[
|
||||||
|
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
||||||
|
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
||||||
|
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
||||||
|
].map((r) => (
|
||||||
|
<div key={r.label} className="flex items-center gap-2">
|
||||||
|
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full", r.color)}
|
||||||
|
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-14 text-right tabular-nums">{fmtBgpNum(r.val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(rscBgpSnippet(session)).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1800)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||||
|
{[
|
||||||
|
{ label: "Router ID", value: session.routerId },
|
||||||
|
{ label: "Hold / KA", value: `${session.holdTime}s / ${session.keepalive}s` },
|
||||||
|
{ label: "AFI/SAFI", value: session.afi },
|
||||||
|
{
|
||||||
|
label: "Сообщения ↓/↑",
|
||||||
|
value: `${fmtBgpNum(session.inputMessages)} / ${fmtBgpNum(session.outputMessages)}`,
|
||||||
|
},
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||||||
|
<p className="text-xs font-mono font-medium">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.state === "Established" && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">
|
||||||
|
Префиксы
|
||||||
|
</p>
|
||||||
|
<PrefixBar
|
||||||
|
rx={session.prefixesRx}
|
||||||
|
tx={session.prefixesTx}
|
||||||
|
active={session.prefixesActive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.capabilities.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
|
||||||
|
Capabilities
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{session.capabilities.map((c) => (
|
||||||
|
<CapChip key={c} cap={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.lastError && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
|
||||||
|
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
||||||
|
<p className="text-xs font-mono text-red-500">{session.lastError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
|
||||||
|
RouterOS Export
|
||||||
|
</p>
|
||||||
|
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
|
||||||
|
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
|
||||||
|
{rscBgpSnippet(session)}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
|
||||||
|
copied
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon className="size-3" />
|
||||||
|
{copied ? "Скопировано" : "Копировать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BgpSessionDetail }
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
DataGridColumnHeader,
|
||||||
|
DataGridContainer,
|
||||||
|
DataGridTable,
|
||||||
|
} from "@/components/reui/data-grid"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
|
||||||
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
|
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||||
|
Established: {
|
||||||
|
bg: "bg-emerald-500/10",
|
||||||
|
text: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
dot: "bg-emerald-500",
|
||||||
|
label: "Established",
|
||||||
|
},
|
||||||
|
Active: {
|
||||||
|
bg: "bg-amber-500/10",
|
||||||
|
text: "text-amber-600 dark:text-amber-400",
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
label: "Active",
|
||||||
|
},
|
||||||
|
Idle: {
|
||||||
|
bg: "bg-slate-500/10",
|
||||||
|
text: "text-slate-500 dark:text-slate-400",
|
||||||
|
dot: "bg-slate-500",
|
||||||
|
label: "Idle",
|
||||||
|
},
|
||||||
|
Connect: {
|
||||||
|
bg: "bg-blue-500/10",
|
||||||
|
text: "text-blue-600 dark:text-blue-400",
|
||||||
|
dot: "bg-blue-500",
|
||||||
|
label: "Connect",
|
||||||
|
},
|
||||||
|
OpenSent: {
|
||||||
|
bg: "bg-violet-500/10",
|
||||||
|
text: "text-violet-600 dark:text-violet-400",
|
||||||
|
dot: "bg-violet-500",
|
||||||
|
label: "OpenSent",
|
||||||
|
},
|
||||||
|
OpenConfirm: {
|
||||||
|
bg: "bg-violet-500/10",
|
||||||
|
text: "text-violet-600 dark:text-violet-400",
|
||||||
|
dot: "bg-violet-500",
|
||||||
|
label: "OpenConfirm",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||||
|
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
||||||
|
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateBadge({ state }: { state: BgpState }) {
|
||||||
|
const s = STATE_STYLE[state]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
s.bg,
|
||||||
|
s.text,
|
||||||
|
"border-current/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: BgpType }) {
|
||||||
|
const s = TYPE_STYLE[type]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||||
|
s.bg,
|
||||||
|
s.text,
|
||||||
|
"border-current/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BgpSessionsDataGridProps {
|
||||||
|
sessions: BgpSessionRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<BgpSessionRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "serverLabel",
|
||||||
|
accessorKey: "serverLabel",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роутер" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const expanded = row.getIsExpanded()
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Роутер",
|
||||||
|
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "peerIp",
|
||||||
|
accessorKey: "peerIp",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Peer IP" />,
|
||||||
|
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remoteAs",
|
||||||
|
accessorKey: "remoteAs",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Remote AS" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const as = row.original.remoteAs
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 font-mono">
|
||||||
|
<span>AS{as}</span>
|
||||||
|
{BGP_AS_NAMES[as] && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "description",
|
||||||
|
accessorKey: "description",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||||
|
{row.original.description}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
accessorKey: "type",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||||
|
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
accessorKey: "state",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||||
|
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
accessorKey: "uptime",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Uptime" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono tabular-nums text-muted-foreground">
|
||||||
|
{row.original.uptime ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prefixesRx",
|
||||||
|
accessorKey: "prefixesRx",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↓" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const n = row.original.prefixesRx
|
||||||
|
return n > 0 ? (
|
||||||
|
<span className="font-mono tabular-nums text-emerald-600 dark:text-emerald-400 text-right block">
|
||||||
|
{fmtBgpNum(n)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prefixesTx",
|
||||||
|
accessorKey: "prefixesTx",
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↑" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const n = row.original.prefixesTx
|
||||||
|
return n > 0 ? (
|
||||||
|
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
|
||||||
|
{fmtBgpNum(n)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: sessions,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<NetworkIcon className="size-4" />}
|
||||||
|
title="Нет BGP-сессий"
|
||||||
|
description="Измените фильтры или проверьте подключение к роутерам"
|
||||||
|
className="border-0 py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={sessions.length}
|
||||||
|
isLoading={false}
|
||||||
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||||
|
>
|
||||||
|
<DataGridContainer>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridContainer>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { Server } from "@/lib/data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
|
||||||
|
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const ROS_FEATURES = [
|
||||||
|
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
||||||
|
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
||||||
|
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
||||||
|
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
||||||
|
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
||||||
|
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
||||||
|
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
||||||
|
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
||||||
|
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
||||||
|
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ServerExpandedDetailProps {
|
||||||
|
server: Server
|
||||||
|
isLive: boolean
|
||||||
|
isPolling: boolean
|
||||||
|
onPoll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) {
|
||||||
|
const ver = rosVer(s.os)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||||
|
{s.model && s.model !== "—" && (
|
||||||
|
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
||||||
|
)}
|
||||||
|
{s.uptime && (
|
||||||
|
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
||||||
|
)}
|
||||||
|
{s.cpuLoad != null && (
|
||||||
|
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
||||||
|
)}
|
||||||
|
{s.asn && (
|
||||||
|
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
||||||
|
)}
|
||||||
|
{s.ipv6Address && (
|
||||||
|
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
||||||
|
)}
|
||||||
|
{s.vrfNames?.map((v) => (
|
||||||
|
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
||||||
|
))}
|
||||||
|
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
|
||||||
|
{s.polledAt ? (
|
||||||
|
<span className="text-muted-foreground/50 text-[11px]">
|
||||||
|
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLive && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs shrink-0"
|
||||||
|
disabled={isPolling}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPoll() }}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
|
||||||
|
{isPolling ? "Опрос…" : "Опросить сейчас"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Возможности RouterOS
|
||||||
|
</p>
|
||||||
|
<RosBadge os={s.os} />
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{ver >= 715
|
||||||
|
? "✓ Актуальная версия — все ключевые фичи доступны"
|
||||||
|
: ver >= 710
|
||||||
|
? "⚠ Рекомендуется обновление до 7.15+"
|
||||||
|
: s.os !== "—"
|
||||||
|
? "✗ Устаревшая версия — требуется обновление"
|
||||||
|
: "Нет данных — нажмите «Опросить сейчас»"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||||
|
{ROS_FEATURES.map((f) => {
|
||||||
|
const ok = ver >= f.minVer
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.name}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
||||||
|
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ok
|
||||||
|
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
||||||
|
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
|
||||||
|
{f.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
||||||
|
{f.label} · {f.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServerExpandedDetail }
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import {
|
||||||
|
type Column,
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import type { Server, ServerType } from "@/lib/data"
|
||||||
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
|
import { Flag } from "@/components/flag"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
DataGridContainer,
|
||||||
|
DataGridTable,
|
||||||
|
} from "@/components/reui/data-grid"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
|
||||||
|
import {
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
HomeIcon,
|
||||||
|
ServerIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
WifiIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
PowerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<ServerType, string> = {
|
||||||
|
"jump-host": "JumpHost",
|
||||||
|
"exit-node": "Exit Node",
|
||||||
|
"home-router": "Home Router",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<ServerType, string> = {
|
||||||
|
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
||||||
|
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
||||||
|
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELL_PAD = "py-3"
|
||||||
|
const CELL_PAD_FIRST = "pl-5 py-3"
|
||||||
|
const CELL_PAD_LAST = "pr-4 py-3"
|
||||||
|
|
||||||
|
function rosVer(os: string): number {
|
||||||
|
const m = os.match(/(\d+)\.(\d+)/)
|
||||||
|
if (!m) return 0
|
||||||
|
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: ServerType }) {
|
||||||
|
const icon =
|
||||||
|
type === "jump-host" ? <ServerIcon className="size-3 mr-1" />
|
||||||
|
: type === "exit-node" ? <NetworkIcon className="size-3 mr-1" />
|
||||||
|
: <HomeIcon className="size-3 mr-1" />
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center text-xs font-medium border rounded px-2 py-0.5", TYPE_STYLES[type])}>
|
||||||
|
{icon}{TYPE_LABELS[type]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RosBadge({ os }: { os: string }) {
|
||||||
|
const v = rosVer(os)
|
||||||
|
const cls = v >= 715
|
||||||
|
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||||
|
: v >= 710
|
||||||
|
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||||
|
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||||
|
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServersTableHeader<TData>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
column: Column<TData, unknown>
|
||||||
|
title: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const sorted = column.getIsSorted()
|
||||||
|
const canSort = column.getCanSort()
|
||||||
|
|
||||||
|
if (!canSort) {
|
||||||
|
return (
|
||||||
|
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||||
|
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{sorted === "asc" ? (
|
||||||
|
<ArrowUpIcon className="size-3 text-foreground" />
|
||||||
|
) : sorted === "desc" ? (
|
||||||
|
<ArrowDownIcon className="size-3 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDownIcon className="size-3 opacity-35" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerRowActionsProps {
|
||||||
|
server: Server
|
||||||
|
isLive: boolean
|
||||||
|
isPolling: boolean
|
||||||
|
onEdit: (server: Server) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onPoll: (id: string) => void
|
||||||
|
onToggleStatus: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerRowActions({
|
||||||
|
server,
|
||||||
|
isLive,
|
||||||
|
isPolling,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onPoll,
|
||||||
|
onToggleStatus,
|
||||||
|
}: ServerRowActionsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||||
|
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||||
|
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||||
|
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||||
|
"hover:bg-muted hover:text-foreground hover:border-border",
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Действия: ${server.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" side="bottom" className="w-52">
|
||||||
|
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
|
||||||
|
<ExternalLinkIcon className="size-4" />
|
||||||
|
Открыть WebFig
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEdit(server)}>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isLive && (
|
||||||
|
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
|
||||||
|
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
|
||||||
|
{isPolling ? "Опрос…" : "Опросить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
|
||||||
|
<PowerIcon className="size-4" />
|
||||||
|
{server.status === "offline" ? "Включить" : "Отключить"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServersDataGridProps {
|
||||||
|
servers: Server[]
|
||||||
|
isLive: boolean
|
||||||
|
pollingIds: Set<string>
|
||||||
|
onPoll: (id: string) => void
|
||||||
|
onEdit: (server: Server) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onToggleStatus: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServersDataGrid({
|
||||||
|
servers,
|
||||||
|
isLive,
|
||||||
|
pollingIds,
|
||||||
|
onPoll,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
}: ServersDataGridProps) {
|
||||||
|
const columns = useMemo<ColumnDef<Server>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<ServersTableHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const s = row.original
|
||||||
|
const expanded = row.getIsExpanded()
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
{expanded
|
||||||
|
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||||
|
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">{s.name}</p>
|
||||||
|
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
||||||
|
{s.ipv6Address && (
|
||||||
|
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
||||||
|
{s.ipv6Address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Имя / Хост",
|
||||||
|
headerClassName: CELL_PAD_FIRST,
|
||||||
|
cellClassName: CELL_PAD_FIRST,
|
||||||
|
expandedContent: (row: Server) => (
|
||||||
|
<ServerExpandedDetail
|
||||||
|
server={row}
|
||||||
|
isLive={isLive}
|
||||||
|
isPolling={pollingIds.has(row.id)}
|
||||||
|
onPoll={() => onPoll(row.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "type",
|
||||||
|
accessorKey: "type",
|
||||||
|
header: ({ column }) => <ServersTableHeader column={column} title="Тип" />,
|
||||||
|
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||||
|
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "model",
|
||||||
|
accessorKey: "model",
|
||||||
|
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
|
||||||
|
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
|
||||||
|
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "os",
|
||||||
|
accessorKey: "os",
|
||||||
|
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
|
||||||
|
cell: ({ row }) => <RosBadge os={row.original.os} />,
|
||||||
|
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "site",
|
||||||
|
accessorKey: "site",
|
||||||
|
header: ({ column }) => <ServersTableHeader column={column} title="Площадка" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Flag code={row.original.country} />
|
||||||
|
<span className="font-medium text-sm">{row.original.site}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: "Площадка", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wan",
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const s = row.original
|
||||||
|
if (s.type === "home-router" && s.wanUplinks?.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{s.wanUplinks.map((w) => (
|
||||||
|
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||||
|
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
||||||
|
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
||||||
|
<span className="text-muted-foreground">{w.isp}</span>
|
||||||
|
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{s.lanSubnet && (
|
||||||
|
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
||||||
|
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
||||||
|
<ShieldIcon className="size-3" />
|
||||||
|
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.rpkiEnabled && (
|
||||||
|
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
||||||
|
)}
|
||||||
|
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerTitle: "WAN / LAN", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "latency",
|
||||||
|
accessorKey: "latency",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<ServersTableHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const s = row.original
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
"font-mono text-sm block text-right tabular-nums",
|
||||||
|
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
|
||||||
|
)}>
|
||||||
|
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerTitle: "Задержка",
|
||||||
|
headerClassName: cn(CELL_PAD, "text-right"),
|
||||||
|
cellClassName: cn(CELL_PAD, "text-right"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const s = row.original
|
||||||
|
return (
|
||||||
|
<ServerRowActions
|
||||||
|
server={s}
|
||||||
|
isLive={isLive}
|
||||||
|
isPolling={pollingIds.has(s.id)}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onPoll={onPoll}
|
||||||
|
onToggleStatus={onToggleStatus}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
size: 56,
|
||||||
|
meta: {
|
||||||
|
headerClassName: CELL_PAD_LAST,
|
||||||
|
cellClassName: CELL_PAD_LAST,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: servers,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ServerIcon className="size-4" />}
|
||||||
|
title="Нет серверов"
|
||||||
|
description="Добавьте первый MikroTik-сервер для мониторинга"
|
||||||
|
className="border-0 py-12"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={servers.length}
|
||||||
|
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||||
|
tableLayout={{
|
||||||
|
rowBorder: true,
|
||||||
|
headerBackground: true,
|
||||||
|
headerBorder: true,
|
||||||
|
columnsResizable: false,
|
||||||
|
}}
|
||||||
|
tableClassNames={{
|
||||||
|
headerRow: "border-b border-border",
|
||||||
|
bodyRow: "group/row",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DataGridContainer border={false} className="rounded-none border-0">
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridContainer>
|
||||||
|
</DataGrid>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ReactNode } from "react"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Filters,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from "@/components/reui/filters"
|
||||||
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
|
|
||||||
|
interface DataPageToolbarProps<T extends string = string> {
|
||||||
|
search?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
|
searchPlaceholder?: string
|
||||||
|
segmented?: {
|
||||||
|
value: T
|
||||||
|
onChange: (value: T) => void
|
||||||
|
options: { value: T; label: string; count?: number }[]
|
||||||
|
}
|
||||||
|
filters?: Filter[]
|
||||||
|
onFiltersChange?: (filters: Filter[]) => void
|
||||||
|
filterFields?: FilterFieldConfig[]
|
||||||
|
countLabel?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataPageToolbar<T extends string = string>({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
searchPlaceholder = "Поиск…",
|
||||||
|
segmented,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
filterFields,
|
||||||
|
countLabel,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: DataPageToolbarProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-3 px-5 py-3 border-b flex-wrap", className)}>
|
||||||
|
{segmented && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={segmented.value}
|
||||||
|
onChange={segmented.onChange}
|
||||||
|
options={segmented.options}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filterFields && filters && onFiltersChange && (
|
||||||
|
<Filters
|
||||||
|
filters={filters}
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={onFiltersChange}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onSearchChange != null && (
|
||||||
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||||||
|
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<Input
|
||||||
|
className="h-6 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={search ?? ""}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{countLabel && (
|
||||||
|
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataPageToolbar, type DataPageToolbarProps }
|
||||||
+76
-37
@@ -1,8 +1,24 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { SearchIcon } from "lucide-react"
|
import {
|
||||||
|
DataGrid,
|
||||||
|
DataGridContainer,
|
||||||
|
DataGridPagination,
|
||||||
|
DataGridTable,
|
||||||
|
} from "@/components/reui/data-grid"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { InboxIcon } from "lucide-react"
|
||||||
|
|
||||||
export interface Column<T> {
|
export interface Column<T> {
|
||||||
key: string
|
key: string
|
||||||
@@ -15,6 +31,9 @@ interface DataTableProps<T extends { id: string }> {
|
|||||||
columns: Column<T>[]
|
columns: Column<T>[]
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
searchKeys?: (keyof T)[]
|
searchKeys?: (keyof T)[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T extends { id: string }>({
|
export function DataTable<T extends { id: string }>({
|
||||||
@@ -22,10 +41,14 @@ export function DataTable<T extends { id: string }>({
|
|||||||
columns,
|
columns,
|
||||||
searchPlaceholder = "Поиск…",
|
searchPlaceholder = "Поиск…",
|
||||||
searchKeys = [],
|
searchKeys = [],
|
||||||
|
isLoading = false,
|
||||||
|
emptyTitle = "Нет записей",
|
||||||
|
emptyDescription,
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!search || searchKeys.length === 0) return data
|
if (!search || searchKeys.length === 0) return data
|
||||||
const s = search.toLowerCase()
|
const s = search.toLowerCase()
|
||||||
return data.filter((row) =>
|
return data.filter((row) =>
|
||||||
@@ -33,44 +56,60 @@ export function DataTable<T extends { id: string }>({
|
|||||||
)
|
)
|
||||||
}, [data, search, searchKeys])
|
}, [data, search, searchKeys])
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||||
|
() =>
|
||||||
|
columns.map((col) => ({
|
||||||
|
id: col.key,
|
||||||
|
accessorKey: col.key,
|
||||||
|
header: col.label,
|
||||||
|
cell: ({ row }) => col.render(row.original),
|
||||||
|
meta: { headerTitle: col.label },
|
||||||
|
})),
|
||||||
|
[columns],
|
||||||
|
)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns: columnDefs,
|
||||||
|
state: { globalFilter },
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b">
|
<DataPageToolbar
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
search={search}
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
onSearchChange={setSearch}
|
||||||
<input
|
searchPlaceholder={searchPlaceholder}
|
||||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
countLabel={`${displayCount} записей`}
|
||||||
placeholder={searchPlaceholder}
|
/>
|
||||||
value={search}
|
<DataGrid
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
table={table}
|
||||||
|
recordCount={filteredData.length}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingMode="skeleton"
|
||||||
|
emptyMessage={
|
||||||
|
<EmptyState
|
||||||
|
icon={<InboxIcon className="size-4" />}
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
className="py-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
}
|
||||||
<span className="text-sm text-muted-foreground ml-1">{filtered.length} записей</span>
|
tableLayout={{ rowBorder: true, headerBackground: true }}
|
||||||
</div>
|
>
|
||||||
<div className="overflow-x-auto">
|
<DataGridContainer border={false}>
|
||||||
<table className="w-full text-sm">
|
<DataGridTable />
|
||||||
<thead>
|
</DataGridContainer>
|
||||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
|
||||||
{columns.map((col) => (
|
</DataGrid>
|
||||||
<th key={col.key} className="text-left font-medium px-5 py-3">
|
|
||||||
{col.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{filtered.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
|
|
||||||
{columns.map((col) => (
|
|
||||||
<td key={col.key} className="px-5 py-3">
|
|
||||||
{col.render(row)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
Empty,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyMedia,
|
||||||
|
EmptyTitle,
|
||||||
|
} from "@/components/ui/empty"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Empty className={cn("border-0 py-16", className)}>
|
||||||
|
<EmptyHeader>
|
||||||
|
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||||
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
|
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||||
|
</EmptyHeader>
|
||||||
|
{action && <EmptyContent>{action}</EmptyContent>}
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EmptyState, type EmptyStateProps }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { UploadIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface FileImportDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onImport: (files: File[]) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
accept = "*",
|
||||||
|
multiple = false,
|
||||||
|
onImport,
|
||||||
|
}: FileImportDialogProps) {
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
|
const [{ files, isDragging, errors }, actions] = useFileUpload({
|
||||||
|
accept,
|
||||||
|
multiple,
|
||||||
|
maxFiles: multiple ? 10 : 1,
|
||||||
|
onError: (errs) => {
|
||||||
|
errs.forEach((e) => toast.error(e))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleImport = useCallback(async () => {
|
||||||
|
const rawFiles = files
|
||||||
|
.map((f) => (f.file instanceof File ? f.file : null))
|
||||||
|
.filter((f): f is File => f != null)
|
||||||
|
if (rawFiles.length === 0) {
|
||||||
|
toast.error("Выберите файл для импорта")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setImporting(true)
|
||||||
|
try {
|
||||||
|
await onImport(rawFiles)
|
||||||
|
actions.clearFiles()
|
||||||
|
onOpenChange(false)
|
||||||
|
toast.success("Импорт выполнен")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}, [actions, files, onImport, onOpenChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
|
||||||
|
isDragging ? "border-primary bg-primary/5" : "border-border",
|
||||||
|
)}
|
||||||
|
onDragEnter={actions.handleDragEnter}
|
||||||
|
onDragLeave={actions.handleDragLeave}
|
||||||
|
onDragOver={actions.handleDragOver}
|
||||||
|
onDrop={actions.handleDrop}
|
||||||
|
>
|
||||||
|
<UploadIcon className="size-8 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Перетащите файл сюда или выберите на диске
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
|
||||||
|
Выбрать файл
|
||||||
|
</Button>
|
||||||
|
<input {...actions.getInputProps()} className="sr-only" />
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="w-full text-left text-sm">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.id} className="truncate font-mono text-xs">
|
||||||
|
{f.file instanceof File ? f.file.name : f.file.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{errors.map((e) => (
|
||||||
|
<p key={e} className="text-xs text-destructive">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
|
||||||
|
{importing ? "Импорт…" : "Импортировать"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FileImportDialog, type FileImportDialogProps }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field"
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({ label, hint, error, required, children }: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<Field data-invalid={!!error}>
|
||||||
|
<FieldLabel>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</FieldLabel>
|
||||||
|
{children}
|
||||||
|
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
|
||||||
|
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormField, type FormFieldProps }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user