Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2de8d3188 | ||
|
|
d3a2d38b37 | ||
|
|
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",
|
||||
"tag": "v1.1.0",
|
||||
"publishedAt": "2026-05-12T10:30:14.398Z",
|
||||
"releaseUrl": "https://git.shts.su/denozord/MikrotikManager-3/releases/tag/v1.1.0",
|
||||
"version": "1.3.4",
|
||||
"tag": "v1.3.4",
|
||||
"publishedAt": "2026-05-12T13:50:06.198Z",
|
||||
"releaseUrl": "",
|
||||
"commits": [
|
||||
{
|
||||
"sha": "4b92a6eb81ce64fad2ca61f5fa94342959a45349",
|
||||
"shortSha": "4b92a6e",
|
||||
"subject": "feat: release v1.1.0 with enhancements and refactoring",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "9ccc8459d77032dae8e7f67ac0df0287d7553341",
|
||||
"shortSha": "9ccc845",
|
||||
"subject": "feat: update application versioning and enhance release management",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "f282585b525fe787ab6bdbd2f83c9c848b376771",
|
||||
"shortSha": "f282585",
|
||||
"subject": "feat: enhance network configuration handling in updater script",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "40e2040fb05717283d0ee2b7d0a6b3315abecf73",
|
||||
"shortSha": "40e2040",
|
||||
"subject": "feat: refactor theme management and remove next-themes dependency",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "bb07ba0f69966e4b5533f6b4f0d7d96cef0916f1",
|
||||
"shortSha": "bb07ba0",
|
||||
"subject": "feat: implement database backup and restore functionality in settings page",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "1fefed2aa0dbd51f893750e2e179f65bd9386372",
|
||||
"shortSha": "1fefed2",
|
||||
"subject": "chore: enhance data source configuration and mock data handling",
|
||||
"sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
|
||||
"shortSha": "3ce4179",
|
||||
"subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "4489dfb2d62e836b5415b9ead409851bef995eaf",
|
||||
"shortSha": "4489dfb",
|
||||
"subject": "chore: enhance frontend configuration for backend integration",
|
||||
"sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
|
||||
"shortSha": "1a47905",
|
||||
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "4b240d70d0fbc2143cc657f78f932d003ad5d406",
|
||||
"shortSha": "4b240d7",
|
||||
"subject": "chore: add server and server_snapshots tables to database schema",
|
||||
"sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
|
||||
"shortSha": "b51d979",
|
||||
"subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "63252ae85ed03b772ede2be97a27f1605cc16d0d",
|
||||
"shortSha": "63252ae",
|
||||
"subject": "chore: enhance image reference resolution in updater script",
|
||||
"sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
|
||||
"shortSha": "bc01da1",
|
||||
"subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "a359874a42add6b0f4f2f4844ba6e19268bdc05f",
|
||||
"shortSha": "a359874",
|
||||
"subject": "chore: enhance Dockerfile for backend to rebuild better-sqlite3",
|
||||
"sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
|
||||
"shortSha": "500a543",
|
||||
"subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "4ec247f2c02edfccd775c3e5c644c28844358c90",
|
||||
"shortSha": "4ec247f",
|
||||
"subject": "chore: update Docker Compose configuration for data volume management",
|
||||
"sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
|
||||
"shortSha": "60024fa",
|
||||
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "2697a35c0a719396fbe5fdaf6045cb9ca49c28fa",
|
||||
"shortSha": "2697a35",
|
||||
"subject": "chore: streamline Docker workflow and enhance build efficiency",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "7295545cde2fc7b0674ba17b3447d89be2f85218",
|
||||
"shortSha": "7295545",
|
||||
"subject": "chore: add updater image configuration to Docker workflow",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "a5b5a2a3d390bf4f6fb0986a917d43532844a346",
|
||||
"shortSha": "a5b5a2a",
|
||||
"subject": "chore: enhance Dockerfiles and package.json for improved build process",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "01596864dc7801275eefcb67aad67b58785d3a28",
|
||||
"shortSha": "0159686",
|
||||
"subject": "chore: update Dockerfiles to ignore scripts during npm install",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "02119a3acde256a92edd06cf4d00337bb53088ee",
|
||||
"shortSha": "02119a3",
|
||||
"subject": "chore: configure dynamic image naming for frontend and backend in Docker workflow",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "c36994c36b78063068aff5fa509d1d26c522de51",
|
||||
"shortSha": "c36994c",
|
||||
"subject": "chore: update configuration and dependencies",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "dfbaa859ad7616159e373ca50632efd2ad77e8e3",
|
||||
"shortSha": "dfbaa85",
|
||||
"subject": "chore: isolate frontend and backend via npm workspaces and @mmapp/contracts",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "b9a75b6831e4ef27726bedbab37f385366c33067",
|
||||
"shortSha": "b9a75b6",
|
||||
"subject": "feat: implement internet path functionality with backend support",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "11ad94f67d4a3f5567d3e6e9cd8fe7a587e86df1",
|
||||
"shortSha": "11ad94f",
|
||||
"subject": "feat: integrate sonner for toast notifications and enhance UI feedback",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "84ecd4f0619cb434918e3d36d1d2d8d61dc0c759",
|
||||
"shortSha": "84ecd4f",
|
||||
"subject": "feat: enhance backups page with live data loading and backup job management",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
},
|
||||
{
|
||||
"sha": "6d8379501caf263fa2def609c91601d8b4bb4298",
|
||||
"shortSha": "6d83795",
|
||||
"subject": "refactor: replace custom API fetch logic with requestJson utility across multiple pages",
|
||||
"author": "Denozordec",
|
||||
"type": "refactor"
|
||||
},
|
||||
{
|
||||
"sha": "5f31bb47fb11592ef1dd1c576105efb07cff584e",
|
||||
"shortSha": "5f31bb4",
|
||||
"subject": "chore: synchronize pending app/backend updates and repository hygiene",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "bdb9b72fac5e9cf03faa6e8df463759a9defbf24",
|
||||
"shortSha": "bdb9b72",
|
||||
"subject": "Init 2",
|
||||
"author": "Denozordec",
|
||||
"type": "other"
|
||||
},
|
||||
{
|
||||
"sha": "ce00c4c671834df0c3277480ecf1a7bbd03cf3f4",
|
||||
"shortSha": "ce00c4c",
|
||||
"subject": "Update .gitignore to include additional build and log files",
|
||||
"author": "Denozordec",
|
||||
"type": "other"
|
||||
},
|
||||
{
|
||||
"sha": "f3f831653f48c24ae02d15cf10b3e929d4d63e14",
|
||||
"shortSha": "f3f8316",
|
||||
"subject": "Init commit",
|
||||
"author": "Denozordec",
|
||||
"type": "other"
|
||||
"type": "fix"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
# v1.1.0
|
||||
# v1.3.4
|
||||
|
||||
Версия **1.1.0**.
|
||||
Версия **1.3.4**.
|
||||
|
||||
## feat
|
||||
## fix
|
||||
|
||||
- feat: release v1.1.0 with enhancements and refactoring (`4b92a6e`, Denozordec)
|
||||
- feat: update application versioning and enhance release management (`9ccc845`, Denozordec)
|
||||
- feat: enhance network configuration handling in updater script (`f282585`, Denozordec)
|
||||
- feat: refactor theme management and remove next-themes dependency (`40e2040`, Denozordec)
|
||||
- feat: implement database backup and restore functionality in settings page (`bb07ba0`, Denozordec)
|
||||
- feat: implement internet path functionality with backend support (`b9a75b6`, Denozordec)
|
||||
- feat: integrate sonner for toast notifications and enhance UI feedback (`11ad94f`, Denozordec)
|
||||
- feat: enhance backups page with live data loading and backup job management (`84ecd4f`, Denozordec)
|
||||
- fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов (`1a47905`, Denozordec)
|
||||
- fix(backend): улучшить обработку хранилищ доверия для сертификатов (`b51d979`, Denozordec)
|
||||
- fix(backend): добавить функции для работы с IP-адресами и DNS-записями (`bc01da1`, Denozordec)
|
||||
- fix(backend): улучшить уникальность идентификаторов сертификатов (`500a543`, Denozordec)
|
||||
- fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов (`60024fa`, Denozordec)
|
||||
- fix(ui): добавить новые компоненты для управления сертификатами (`53ea0e7`, Denozordec)
|
||||
- fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки (`6c8ca17`, Denozordec)
|
||||
|
||||
## chore
|
||||
|
||||
- chore: enhance data source configuration and mock data handling (`1fefed2`, Denozordec)
|
||||
- chore: enhance frontend configuration for backend integration (`4489dfb`, Denozordec)
|
||||
- chore: add server and server_snapshots tables to database schema (`4b240d7`, Denozordec)
|
||||
- chore: enhance image reference resolution in updater script (`63252ae`, Denozordec)
|
||||
- chore: enhance Dockerfile for backend to rebuild better-sqlite3 (`a359874`, Denozordec)
|
||||
- chore: update Docker Compose configuration for data volume management (`4ec247f`, Denozordec)
|
||||
- chore: streamline Docker workflow and enhance build efficiency (`2697a35`, Denozordec)
|
||||
- chore: add updater image configuration to Docker workflow (`7295545`, Denozordec)
|
||||
- chore: enhance Dockerfiles and package.json for improved build process (`a5b5a2a`, Denozordec)
|
||||
- chore: update Dockerfiles to ignore scripts during npm install (`0159686`, Denozordec)
|
||||
- chore: configure dynamic image naming for frontend and backend in Docker workflow (`02119a3`, Denozordec)
|
||||
- chore: update configuration and dependencies (`c36994c`, Denozordec)
|
||||
- chore: isolate frontend and backend via npm workspaces and @mmapp/contracts (`dfbaa85`, Denozordec)
|
||||
- chore: synchronize pending app/backend updates and repository hygiene (`5f31bb4`, Denozordec)
|
||||
|
||||
## refactor
|
||||
|
||||
- refactor: replace custom API fetch logic with requestJson utility across multiple pages (`6d83795`, Denozordec)
|
||||
|
||||
## other
|
||||
|
||||
- Init 2 (`bdb9b72`, Denozordec)
|
||||
- Update .gitignore to include additional build and log files (`ce00c4c`, Denozordec)
|
||||
- Init commit (`f3f8316`, Denozordec)
|
||||
- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
|
||||
- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, Denozordec)
|
||||
|
||||
@@ -13,6 +13,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
|
||||
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
|
||||
|
||||
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
|
||||
const CONVENTIONAL_RE =
|
||||
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
||||
const MINOR_TYPES = new Set(["feat"])
|
||||
@@ -139,6 +140,23 @@ function getCommitsSince(tag) {
|
||||
)
|
||||
if (!raw) return []
|
||||
|
||||
return parseCommitLog(raw)
|
||||
}
|
||||
|
||||
function getRecentCommits(limit = MANIFEST_COMMIT_LIMIT) {
|
||||
const raw = git(
|
||||
"log",
|
||||
"-n",
|
||||
String(limit),
|
||||
"HEAD",
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
|
||||
)
|
||||
if (!raw) return []
|
||||
|
||||
return parseCommitLog(raw)
|
||||
}
|
||||
|
||||
function parseCommitLog(raw) {
|
||||
return raw
|
||||
.split("\x1e")
|
||||
.map((entry) => entry.trim())
|
||||
@@ -210,7 +228,7 @@ function main() {
|
||||
if (commits.length === 0) {
|
||||
const deployTag = latestTag ?? `v${baseFromTag}`
|
||||
const deployVersion = baseFromTag
|
||||
const displayCommits = latestTag ? getCommitsSince(getPreviousTag(latestTag)) : []
|
||||
const displayCommits = getRecentCommits()
|
||||
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
@@ -244,7 +262,7 @@ function main() {
|
||||
tag,
|
||||
publishedAt,
|
||||
releaseUrl,
|
||||
commits: toManifestCommits(commits),
|
||||
commits: toManifestCommits(getRecentCommits()),
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
@@ -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
|
||||
npm run dev
|
||||
npm --prefix backend run dev
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend: `http://localhost:8000`
|
||||
- Health check backend: `http://localhost:8000/health`
|
||||
| Сервис | URL | Проверка |
|
||||
|--------|-----|----------|
|
||||
| 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`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||
- Сообщения коммитов и ожидаемый 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, отступы, сетка, типографика, композиция).
|
||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
|
||||
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ alwaysApply: true
|
||||
|
||||
**Язык:** все ответы пользователю — **только на русском**.
|
||||
|
||||
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
|
||||
|
||||
## 1. Документация в первую очередь
|
||||
|
||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
---
|
||||
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
|
||||
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Версионирование релизов
|
||||
|
||||
## Generate Commit Message (критично)
|
||||
|
||||
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
|
||||
|
||||
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
|
||||
- Body и footer (если нужны) — **только на русском**.
|
||||
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
|
||||
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
|
||||
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
|
||||
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
|
||||
|
||||
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||
|
||||
## Patch (`1.0.x`)
|
||||
|
||||
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||
|
||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
|
||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||
|
||||
## Minor (`1.x.0`)
|
||||
@@ -23,10 +35,11 @@ alwaysApply: true
|
||||
|
||||
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||
- `feat(filters): добавить синхронизацию BGP in` — minor
|
||||
- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо
|
||||
- `chore(ci): добавить job prepare-release` — хорошо
|
||||
- `feat: add release modal` — плохо (subject не на русском)
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||
|
||||
Подробности — раздел CI/CD в `README.md`.
|
||||
|
||||
@@ -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 HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
ENV BACKEND_INTERNAL_URL=http://backend:8000
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
|
||||
@@ -215,7 +215,7 @@ npm --prefix backend run db:studio
|
||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
||||
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
||||
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc).
|
||||
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
|
||||
|
||||
## Прод-развёртывание Docker
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@@ -685,23 +686,6 @@ const INIT_TG: TelegramConfig = {
|
||||
|
||||
// ─── 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 }) {
|
||||
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",
|
||||
)}>
|
||||
{/* 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 */}
|
||||
<SeverityDot severity={rule.severity} />
|
||||
|
||||
+46
-53
@@ -1,18 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -22,13 +28,26 @@ export default function AsnsPage() {
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.org.toLowerCase().includes(q) ||
|
||||
String(r.prefixes).includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||
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 size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||
</>
|
||||
@@ -55,57 +74,31 @@ export default function AsnsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
searchKeys={["asn", "org", "prefixes"]}
|
||||
columns={[
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "org",
|
||||
label: "Имя / организация",
|
||||
render: (d) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
|
||||
{d.org}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prefixes",
|
||||
label: "Префиксов",
|
||||
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
countLabel={`${filtered.length} ASN`}
|
||||
/>
|
||||
<AsnsDataGrid
|
||||
asns={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт ASN"
|
||||
description="Загрузите CSV или JSON со списком автономных систем"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+223
-209
@@ -2,9 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
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 type { Backup, Server } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -20,54 +25,19 @@ import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
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({ 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>
|
||||
)
|
||||
}
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,6 +86,7 @@ export default function BackupsPage() {
|
||||
const [opBusy, setOpBusy] = useState(false)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||
|
||||
// Schedule
|
||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||
@@ -140,13 +111,38 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
toast.success("Настройки сохранены")
|
||||
if (scheduleSaveBusy) return
|
||||
setScheduleSaveBusy(true)
|
||||
setOpError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
await putBackupScheduleSettings(backendUrl, {
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
hour: schedule.hour,
|
||||
minute: schedule.minute,
|
||||
weekDay: schedule.weekDay,
|
||||
monthDay: schedule.monthDay,
|
||||
keepCount: schedule.keepCount,
|
||||
format: schedule.format,
|
||||
serverIds: [...selectedServers],
|
||||
})
|
||||
toast.success("Настройки сохранены")
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания")
|
||||
} finally {
|
||||
setScheduleSaveBusy(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Manual backup sheet
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const [manualStep, setManualStep] = useState(1)
|
||||
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||
const [manualNotes, setManualNotes] = useState("")
|
||||
const [restoreOpen, setRestoreOpen] = useState(false)
|
||||
const [restoreTarget, setRestoreTarget] = useState<Backup | null>(null)
|
||||
function toggleManualServer(id: string) {
|
||||
setManualServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -171,14 +167,28 @@ export default function BackupsPage() {
|
||||
setLoading(true)
|
||||
setOpError(null)
|
||||
try {
|
||||
const [serversRows, backupsRows] = await Promise.all([
|
||||
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
|
||||
listServers(backendUrl),
|
||||
listBackups(backendUrl),
|
||||
getBackupScheduleSettings(backendUrl),
|
||||
])
|
||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
||||
setLiveServers(mappedServers)
|
||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
||||
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
|
||||
setSchedule({
|
||||
enabled: scheduleRow.enabled,
|
||||
frequency: scheduleRow.frequency,
|
||||
hour: scheduleRow.hour,
|
||||
minute: scheduleRow.minute,
|
||||
weekDay: scheduleRow.weekDay,
|
||||
monthDay: scheduleRow.monthDay,
|
||||
keepCount: scheduleRow.keepCount,
|
||||
format: scheduleRow.format,
|
||||
})
|
||||
const serverIds = scheduleRow.serverIds.length > 0
|
||||
? scheduleRow.serverIds
|
||||
: mappedServers.map((s) => s.id)
|
||||
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
} finally {
|
||||
@@ -319,7 +329,7 @@ export default function BackupsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}
|
||||
onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualStep(1); setManualOpen(true) }}
|
||||
disabled={loading}
|
||||
>
|
||||
<PlusIcon className="size-4" />Новый бэкап
|
||||
@@ -387,83 +397,29 @@ export default function BackupsPage() {
|
||||
|
||||
{/* ── История ──────────────────────────────────────────────────── */}
|
||||
{tab === "history" && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{([
|
||||
{ value: "all", label: "Все", count: backupList.length },
|
||||
{ value: "auto", label: "Авто", count: autoCount },
|
||||
{ value: "manual", label: "Вручную", count: manualCount },
|
||||
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
|
||||
<button key={t.value} onClick={() => setKindFilter(t.value)}
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: kindFilter,
|
||||
onChange: setKindFilter,
|
||||
options: [
|
||||
{ value: "all", label: "Все", count: backupList.length },
|
||||
{ value: "auto", label: "Авто", count: autoCount },
|
||||
{ value: "manual", label: "Вручную", count: manualCount },
|
||||
],
|
||||
}}
|
||||
countLabel={`${filtered.length} бэкапов`}
|
||||
/>
|
||||
<BackupsDataGrid
|
||||
backups={filtered}
|
||||
onDownload={handleDownload}
|
||||
onRestore={(b) => {
|
||||
setRestoreTarget(b)
|
||||
setRestoreOpen(true)
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||||
@@ -480,11 +436,11 @@ export default function BackupsPage() {
|
||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||
</div>
|
||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||
<FormToggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||
<Field label="Частота">
|
||||
<FormField label="Частота">
|
||||
<SegmentedControl
|
||||
value={schedule.frequency}
|
||||
onChange={(v) => setSched("frequency", v)}
|
||||
@@ -494,10 +450,10 @@ export default function BackupsPage() {
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
{schedule.frequency === "weekly" && (
|
||||
<Field label="День недели">
|
||||
<FormField label="День недели">
|
||||
<div className="flex gap-1">
|
||||
{WEEK_DAYS.map((d, i) => (
|
||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||
@@ -511,18 +467,18 @@ export default function BackupsPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{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"
|
||||
value={schedule.monthDay}
|
||||
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="relative">
|
||||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||||
@@ -544,15 +500,15 @@ export default function BackupsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<Input type="number" min={1} max={90} className="font-mono"
|
||||
value={schedule.keepCount}
|
||||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||||
</Field>
|
||||
<Field label="Формат файла">
|
||||
</FormField>
|
||||
<FormField label="Формат файла">
|
||||
<SegmentedControl
|
||||
value={schedule.format}
|
||||
onChange={(v) => setSched("format", v)}
|
||||
@@ -561,7 +517,7 @@ export default function BackupsPage() {
|
||||
{ value: "backup", label: ".backup" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -572,7 +528,7 @@ export default function BackupsPage() {
|
||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||
|
||||
<Field label="Тип хранилища">
|
||||
<FormField label="Тип хранилища">
|
||||
<SegmentedControl
|
||||
value={storage.type}
|
||||
onChange={(v) => setStore("type", v)}
|
||||
@@ -583,45 +539,45 @@ export default function BackupsPage() {
|
||||
{ value: "smb", label: "SMB" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
{storage.type === "local" && (
|
||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
||||
<FormField label="Путь сохранения" hint="Директория на сервере приложения">
|
||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||
value={storage.localPath}
|
||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{storage.type !== "local" && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Field label="Хост">
|
||||
<FormField label="Хост">
|
||||
<Input className="font-mono" placeholder="192.168.1.100"
|
||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="Порт">
|
||||
<FormField label="Порт">
|
||||
<Input className="font-mono"
|
||||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{storage.type === "smb" && (
|
||||
<Field label="Общая папка (Share)">
|
||||
<FormField label="Общая папка (Share)">
|
||||
<Input className="font-mono" placeholder="backups"
|
||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Пользователь">
|
||||
<FormField label="Пользователь">
|
||||
<Input className="font-mono" placeholder="backup-user"
|
||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||
</Field>
|
||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||
</FormField>
|
||||
<FormField label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={storage.showPassword ? "text" : "password"}
|
||||
@@ -636,13 +592,13 @@ export default function BackupsPage() {
|
||||
{storage.showPassword ? "скрыть" : "показ"}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Field label="Удалённый путь">
|
||||
<FormField label="Удалённый путь">
|
||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -711,7 +667,7 @@ export default function BackupsPage() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="lg:col-span-2 flex items-center gap-3">
|
||||
<Button onClick={handleSave} className="gap-2">
|
||||
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</div>
|
||||
@@ -722,71 +678,129 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
|
||||
{/* ══ 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">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый бэкап</SheetTitle>
|
||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div 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>
|
||||
<Stepper value={manualStep} onValueChange={setManualStep} 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">Заметка</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>
|
||||
{liveServers.map((s) => {
|
||||
const checked = manualServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
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>}
|
||||
</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} />
|
||||
{liveServers.map((s) => {
|
||||
const checked = manualServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
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>}
|
||||
</div>
|
||||
</div>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Заметка</label>
|
||||
<Input placeholder="Например: перед обновлением BGP"
|
||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||||
</div>
|
||||
</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>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</StepperContent>
|
||||
<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">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1"
|
||||
disabled={manualServers.size === 0 || backupJobId !== null}
|
||||
onClick={handleManualBackup}>
|
||||
Снять бэкап ({manualServers.size})
|
||||
</Button>
|
||||
{manualStep > 1 && (
|
||||
<Button variant="outline" className="flex-1" onClick={() => setManualStep((s) => s - 1)}>
|
||||
Назад
|
||||
</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>
|
||||
</SheetContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+80
-295
@@ -2,7 +2,15 @@
|
||||
|
||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
||||
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 { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -17,49 +25,12 @@ import { useDataSource } from "@/lib/data-source"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
||||
type BgpType = "eBGP" | "iBGP"
|
||||
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
||||
type BgpSession = BgpSessionRow
|
||||
type BgpTab = "sessions" | "routers" | "analytics"
|
||||
type StateFilter = "all" | BgpState
|
||||
type TypeFilter = "all" | BgpType
|
||||
|
||||
interface BgpSession {
|
||||
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",
|
||||
}
|
||||
const AS_NAMES = BGP_AS_NAMES
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSIONS: BgpSession[] = [
|
||||
@@ -240,13 +211,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) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
@@ -254,108 +218,6 @@ function fmtNum(n: number) {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BackendBgpSession {
|
||||
@@ -397,159 +259,82 @@ function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
|
||||
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "Established", label: "Established" },
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Idle", label: "Idle" },
|
||||
{ value: "OpenSent", label: "OpenSent" },
|
||||
]
|
||||
const BGP_FILTER_ACCESSORS = {
|
||||
state: (s: BgpSession) => s.state,
|
||||
type: (s: BgpSession) => s.type,
|
||||
afi: (s: BgpSession) => s.afi,
|
||||
}
|
||||
|
||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
|
||||
const q = search.toLowerCase()
|
||||
const filtered = useMemo(() => sessions.filter(s => {
|
||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
|
||||
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
|
||||
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
|
||||
return true
|
||||
}), [sessions, q, stateFilter, typeFilter])
|
||||
const filtered = useMemo(() => {
|
||||
const base = sessions.filter((s) => {
|
||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||
if (
|
||||
q &&
|
||||
!s.peerIp.includes(q) &&
|
||||
!s.description.toLowerCase().includes(q) &&
|
||||
!s.serverLabel.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 (
|
||||
<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">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
<th className="w-8" />
|
||||
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{filtered.map(s => {
|
||||
const isOpen = expandedId === s.id
|
||||
return (
|
||||
<Fragment key={s.id}>
|
||||
<tr
|
||||
onClick={() => setExpandedId(isOpen ? null : s.id)}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
|
||||
)}>
|
||||
<td className="pl-3 py-2.5">
|
||||
{isOpen
|
||||
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
|
||||
<td className="px-3 py-2.5 font-mono">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>AS{s.remoteAs}</span>
|
||||
{AS_NAMES[s.remoteAs] && (
|
||||
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
|
||||
<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">
|
||||
{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>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: stateFilter,
|
||||
onChange: setStateFilter,
|
||||
options: [
|
||||
{ value: "all", label: "Все", count: sessions.length },
|
||||
{ value: "Established", label: "Established", count: sessions.filter((s) => s.state === "Established").length },
|
||||
{ value: "Active", label: "Active", count: sessions.filter((s) => s.state === "Active").length },
|
||||
{ value: "Idle", label: "Idle", count: sessions.filter((s) => s.state === "Idle").length },
|
||||
{ value: "OpenSent", label: "OpenSent", count: sessions.filter((s) => s.state === "OpenSent").length },
|
||||
],
|
||||
}}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={setAdvancedFilters}
|
||||
filterFields={BGP_FILTER_FIELDS}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="IP, AS, описание…"
|
||||
countLabel={`${filtered.length} из ${sessions.length}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||
{(["all", "eBGP", "iBGP"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<BgpSessionsDataGrid sessions={filtered} />
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -826,8 +611,8 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||||
export default function BgpPage() {
|
||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
+741
-321
File diff suppressed because it is too large
Load Diff
+21
-117
@@ -2,14 +2,22 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
CommunitiesDataGrid,
|
||||
type CommunityRow,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -19,21 +27,8 @@ import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
interface Community {
|
||||
id: string
|
||||
value: string // e.g. "65001:100"
|
||||
name: string
|
||||
description: string
|
||||
type: CommType
|
||||
filterIds: string[] // which filters use this community
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number // e.g. local-pref value
|
||||
enabled: boolean
|
||||
}
|
||||
type Community = CommunityRow
|
||||
type CommType = CommunityRow["type"]
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,28 +95,6 @@ const COMMUNITIES: Community[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<CommType, string> = {
|
||||
"standard": "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise":"No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
"custom": "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<Community["action"], string> = {
|
||||
"permit": "Permit",
|
||||
"deny": "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
"metric": "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
"permit": "text-emerald-500",
|
||||
"deny": "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
"metric": "text-amber-500",
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
@@ -225,84 +198,15 @@ export default function CommunitiesPage() {
|
||||
</div>
|
||||
|
||||
{/* list */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(c => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setSelected(c)}
|
||||
className={cn(
|
||||
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
selected?.id === c.id && "bg-primary/5",
|
||||
!c.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copied === c.value
|
||||
? <CheckIcon className="size-3" />
|
||||
: <CopyIcon className="size-3" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-xs">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
|
||||
{c.prefixCount.toLocaleString("ru-RU")}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<TagIcon className="size-8 opacity-30" />
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<CommunitiesDataGrid
|
||||
communities={filtered}
|
||||
selectedId={selected?.id}
|
||||
copiedValue={copied}
|
||||
onSelect={setSelected}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
{/* ── detail panel ── */}
|
||||
|
||||
+52
-138
@@ -22,8 +22,10 @@ import type { PingProbe, Server, ServerStatus, ServerType } from "@/lib/data"
|
||||
import type { GreTunnel } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, DownloadIcon } from "lucide-react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
@@ -72,11 +74,6 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function formatLossPct(loss: number): string {
|
||||
if (!Number.isFinite(loss)) return "—"
|
||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
@@ -114,22 +111,6 @@ function readMockDashboardStarIds(): Set<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Совпадает с эталоном uptime / servers */
|
||||
function TypeChip({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
@@ -241,56 +222,10 @@ function mapBackendToServer(s: BackendServerRow): Server {
|
||||
}
|
||||
}
|
||||
|
||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
||||
const srv = catalog.find(s => s.id === probe.srcServerId)
|
||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||
|
||||
if (!srv) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status="offline" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
|
||||
{iface}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||
<span className="mt-1 shrink-0 inline-flex">
|
||||
<StatusDot status={srv.status} pulse={srv.status === "online"} />
|
||||
</span>
|
||||
<div className="flex gap-2 min-w-0 flex-1">
|
||||
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
|
||||
<span className="font-mono tabular-nums">{iface}</span>
|
||||
{srv.site && srv.site !== "—" && (
|
||||
<span className="text-muted-foreground/90"> · {srv.site}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
@@ -306,9 +241,10 @@ export default function DashboardPage() {
|
||||
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||
|
||||
const probeServerCatalog = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockServers
|
||||
return liveServersResolved ?? []
|
||||
}, [isLive, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveServersResolved])
|
||||
|
||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
@@ -523,13 +459,15 @@ export default function DashboardPage() {
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbesTable = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null && probesLoading) return []
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
|
||||
const probesSubtitle = useMemo(() => {
|
||||
if (!prefsHydrated) return "Загрузка…"
|
||||
if (!isLive) {
|
||||
const starred = mockActiveProbes.length
|
||||
return starred > 0
|
||||
@@ -541,10 +479,13 @@ export default function DashboardPage() {
|
||||
return n > 0
|
||||
? `${n} на дашборде · последний час (API)`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
}, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
|
||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||
const serverStatusModel = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -571,9 +512,12 @@ export default function DashboardPage() {
|
||||
servers,
|
||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||
}
|
||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||||
}, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError])
|
||||
|
||||
const latencyChartBlock = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -612,7 +556,7 @@ export default function DashboardPage() {
|
||||
subtitle:
|
||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||
}
|
||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
|
||||
const dashboardKpi = useMemo(() => {
|
||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||||
@@ -620,6 +564,24 @@ export default function DashboardPage() {
|
||||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||||
|
||||
const loadingKpi = (sparkColor: string) => ({
|
||||
value: "—",
|
||||
unit: undefined as string | undefined,
|
||||
delta: "Загрузка…",
|
||||
deltaDir: "up" as const,
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor,
|
||||
})
|
||||
|
||||
if (!prefsHydrated) {
|
||||
return {
|
||||
servers: loadingKpi("var(--chart-line-1)"),
|
||||
filters: loadingKpi("var(--chart-line-2)"),
|
||||
bgp: loadingKpi("var(--chart-line-4)"),
|
||||
alerts: loadingKpi("var(--chart-5)"),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLive) {
|
||||
const totalSrv = mockServers.length
|
||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||
@@ -664,7 +626,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 totalSrv = srvList.length
|
||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||
@@ -730,7 +693,7 @@ export default function DashboardPage() {
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
}, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -992,67 +955,18 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-2.5 w-[min(280px,32vw)]">Источник</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{isLive && probesLoading && liveProbes === null && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-6">
|
||||
<div className="h-10 rounded-md bg-muted/50 animate-pulse max-w-md mx-auto" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.map((p) => {
|
||||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-2.5 align-top">
|
||||
<ProbeSourceCell probe={p} catalog={probeServerCatalog} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{formatLossPct(p.loss)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!(isLive && probesLoading && liveProbes === null) && activeProbesTable.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-5 py-8 text-center text-sm text-muted-foreground">
|
||||
{isLive && probesError
|
||||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataPageCard className="rounded-none border-0 shadow-none">
|
||||
<DashboardActiveProbesDataGrid
|
||||
probes={activeProbesTable}
|
||||
catalog={probeServerCatalog}
|
||||
isLoading={isLive && probesLoading && liveProbes === null}
|
||||
emptyDescription={
|
||||
isLive && probesError
|
||||
? "Нет данных о пробах. Проверьте сборщик uptime и настройки проб на странице мониторинга."
|
||||
: "Нет проб с звездой на дашборде. Включите пробу и отметьте ★ в разделе «Мониторинг»."
|
||||
}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
+531
-563
File diff suppressed because it is too large
Load Diff
+46
-56
@@ -1,18 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -22,13 +28,26 @@ export default function DomainsPage() {
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.domain.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||
actions={
|
||||
<>
|
||||
<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 size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||
</>
|
||||
@@ -55,60 +74,31 @@ export default function DomainsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "domain",
|
||||
label: "Домен",
|
||||
render: (d) => <span className="font-medium">{d.domain}</span>,
|
||||
},
|
||||
{
|
||||
key: "resolvedIp",
|
||||
label: "Resolved IP",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
countLabel={`${filtered.length} доменов`}
|
||||
/>
|
||||
<DomainsDataGrid
|
||||
domains={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт доменов"
|
||||
description="Загрузите CSV или JSON со списком доменов"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+55
-329
@@ -2,8 +2,14 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
FiltersDataGrid,
|
||||
type RecursiveRouteLite,
|
||||
} from "@/components/data-grids/filters-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -35,42 +41,6 @@ import { toast } from "sonner"
|
||||
|
||||
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||
|
||||
function RouterSyncMarker({
|
||||
status,
|
||||
}: {
|
||||
status: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
if (status === "skip") {
|
||||
return <span className="size-3.5 shrink-0 block" aria-hidden />
|
||||
}
|
||||
const icon =
|
||||
status === "synced"
|
||||
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
|
||||
: status === "drift"
|
||||
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
|
||||
: status === "missing"
|
||||
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
|
||||
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
|
||||
const title =
|
||||
status === "synced"
|
||||
? "Совпадает с цепочкой bgp-in на MikroTik"
|
||||
: status === "drift"
|
||||
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
|
||||
: status === "missing"
|
||||
? "Эта community не найдена в правиле bgp-in на роутере"
|
||||
: "Не проверено — нажмите «Сверить с роутером»"
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
||||
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
||||
|
||||
@@ -137,16 +107,6 @@ function dedupeRecursiveRoutesByDstAddress(routes: RecursiveRouteLite[]): Recurs
|
||||
})
|
||||
}
|
||||
|
||||
interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
routingTable: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const COMMUNITY_NAMES: Record<string, string> = {
|
||||
"65001:100": "youtube-bypass",
|
||||
"65001:200": "streaming-eu",
|
||||
@@ -185,7 +145,7 @@ function generateCombinedRouterOSConfig(
|
||||
const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||
|
||||
const lines: string[] = [
|
||||
`# RouterLists — конфиг фильтрации`,
|
||||
`# MikrotikManager — конфиг фильтрации`,
|
||||
`# Дата : ${now}`,
|
||||
`# Серверов : ${withRules.length}`,
|
||||
`# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`,
|
||||
@@ -271,7 +231,7 @@ function generateCombinedRouterOSConfig(
|
||||
|
||||
lines.push(`/routing filter rule add \\`)
|
||||
lines.push(` chain=bgp-in \\`)
|
||||
lines.push(` comment="RouterLists: ${server.name}" \\`)
|
||||
lines.push(` comment="MikrotikManager: ${server.name}" \\`)
|
||||
lines.push(` rule="`)
|
||||
lines.push(branches)
|
||||
lines.push(` "`)
|
||||
@@ -478,171 +438,6 @@ function CommunityInput({
|
||||
)
|
||||
}
|
||||
|
||||
// ── filter rule row ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterRow({
|
||||
rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
routerSyncStatus,
|
||||
}: {
|
||||
rule: FilterRule; index: number; isLast: boolean
|
||||
onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void
|
||||
tunnelsList: GreTunnel[]
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
routerSyncStatus?: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const isBlackhole = rule.action === "blackhole"
|
||||
const isRecRef = !isBlackhole && isRecursiveGatewayRef(rule.gatewayTunnelId)
|
||||
const recRowByRef = isRecRef ? recursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4)) : undefined
|
||||
const recRowByHop =
|
||||
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
|
||||
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
|
||||
: undefined
|
||||
const recRow = recRowByRef ?? recRowByHop
|
||||
const treatAsRecursive =
|
||||
!isBlackhole && (isRecRef || !!recRowByHop)
|
||||
const tunnel = !isBlackhole && !treatAsRecursive
|
||||
? tunnelsList.find(t => t.id === rule.gatewayTunnelId)
|
||||
: undefined
|
||||
const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined
|
||||
const communityName = communityNameMap[rule.community] ?? rule.communityName
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"group grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
|
||||
isBlackhole && "bg-red-500/[0.03]",
|
||||
)}>
|
||||
{/* priority */}
|
||||
<div className="flex items-center justify-center text-[11px] font-mono text-muted-foreground/40 select-none">
|
||||
{isLast
|
||||
? <StarIcon className="size-3 text-amber-400 fill-amber-400" aria-label="Наивысший приоритет" />
|
||||
: <span>{index + 1}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* reorder */}
|
||||
<div className="flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={onMoveUp} disabled={index === 0}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronUpIcon className="size-3" />
|
||||
</button>
|
||||
<button onClick={onMoveDown} disabled={isLast}
|
||||
className="text-muted-foreground/50 hover:text-foreground disabled:opacity-20 transition-colors">
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MikroTik sync marker */}
|
||||
<div className="flex items-center justify-center">
|
||||
<RouterSyncMarker
|
||||
status={routerSyncStatus === undefined ? "skip" : routerSyncStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* community */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border font-mono",
|
||||
isBlackhole
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{rule.community}
|
||||
</span>
|
||||
{isBlackhole && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold border
|
||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 uppercase tracking-wide">
|
||||
⊘ blackhole
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{communityName && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">{communityName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* gateway / tunnel — or blackhole target */}
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
{isBlackhole ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full shrink-0 bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs font-medium text-red-600 dark:text-red-400">type=blackhole</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
treatAsRecursive
|
||||
? "bg-sky-500"
|
||||
: tunnel?.status === "up"
|
||||
? "bg-[var(--status-online)]"
|
||||
: tunnel?.status === "degraded"
|
||||
? "bg-[var(--status-degraded)]"
|
||||
: "bg-[var(--status-offline)]",
|
||||
)} />
|
||||
{treatAsRecursive ? (
|
||||
<>
|
||||
<RouteIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium">
|
||||
{recRow ? gatewayFromRecursiveDst(recRow.dstAddress) : rule.gateway}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground border border-border rounded px-1 uppercase tracking-wide">
|
||||
recursive
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{remoteSrv && <Flag code={remoteSrv.country} />}
|
||||
<span className="font-mono text-xs font-medium">{rule.gateway}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{treatAsRecursive ? (
|
||||
recRow ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{recRow.dstAddress}</p>
|
||||
) : isRecRef ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 truncate pl-3">
|
||||
рекурсивный маршрут (нет строки в списке — синхронизируйте «Рекурсивные маршруты»)
|
||||
</p>
|
||||
) : null
|
||||
) : tunnel ? (
|
||||
<p className="text-[11px] text-muted-foreground truncate pl-3">{tunnel.name}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
<p className="text-xs text-muted-foreground truncate">{rule.description || "—"}</p>
|
||||
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-0.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onEdit}>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
className={cn("size-7 p-0 transition-colors",
|
||||
confirmDel
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }}
|
||||
onBlur={() => setConfirmDel(false)}>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── rule sheet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RuleForm {
|
||||
@@ -1496,8 +1291,6 @@ function CopyRulesSheet({
|
||||
|
||||
// ── page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortKey = "community" | "gateway" | "description"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
@@ -1529,11 +1322,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function SortIndicator({ k, sortKey, sortAsc }: { k: SortKey; sortKey: SortKey; sortAsc: boolean }) {
|
||||
if (sortKey !== k) return <ArrowUpDownIcon className="size-3 opacity-30" />
|
||||
return sortAsc ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />
|
||||
}
|
||||
|
||||
export default function FiltersPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
@@ -1601,8 +1389,6 @@ export default function FiltersPage() {
|
||||
}, [isLive, apiFetch])
|
||||
const [selectedServerId, setSelectedServerId] = useState("srv1")
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("community")
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
||||
@@ -1707,24 +1493,14 @@ export default function FiltersPage() {
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
const list = q
|
||||
? currentRules.filter(r =>
|
||||
r.community.includes(q) ||
|
||||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
|
||||
r.gateway.includes(q) ||
|
||||
r.description.toLowerCase().includes(q)
|
||||
)
|
||||
: [...currentRules]
|
||||
if (search) {
|
||||
const mult = sortAsc ? 1 : -1
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === "community") return mult * a.community.localeCompare(b.community)
|
||||
if (sortKey === "gateway") return mult * a.gateway.localeCompare(b.gateway)
|
||||
return mult * a.description.localeCompare(b.description)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}, [currentRules, search, sortKey, sortAsc, communityNameMap])
|
||||
if (!q) return currentRules
|
||||
return currentRules.filter((r) =>
|
||||
r.community.includes(q) ||
|
||||
(communityNameMap[r.community] ?? "").toLowerCase().includes(q) ||
|
||||
r.gateway.includes(q) ||
|
||||
r.description.toLowerCase().includes(q),
|
||||
)
|
||||
}, [currentRules, search, communityNameMap])
|
||||
|
||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
|
||||
@@ -1842,10 +1618,6 @@ export default function FiltersPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSort = (k: SortKey) => {
|
||||
if (sortKey === k) setSortAsc(v => !v); else { setSortKey(k); setSortAsc(true) }
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
@@ -2030,10 +1802,10 @@ export default function FiltersPage() {
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
<DataPageCard>
|
||||
|
||||
{/* selected server header */}
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
||||
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||
<StatusDot status={selectedServer.status} pulse={selectedServer.status === "online"} />
|
||||
<Flag code={selectedServer.country} size={16} />
|
||||
<span className="font-mono text-sm font-semibold">{selectedServer.name}</span>
|
||||
@@ -2062,22 +1834,23 @@ export default function FiltersPage() {
|
||||
|
||||
{currentRules.length === 0 ? (
|
||||
/* empty state */
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<NetworkIcon className="size-8 text-muted-foreground/20" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Нет правил фильтрации</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
||||
? "Добавьте правило: BGP community → GRE-шлюз"
|
||||
: "Сначала добавьте GRE-туннели для этого сервера"}
|
||||
</p>
|
||||
</div>
|
||||
{(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет правил фильтрации"
|
||||
description={
|
||||
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId))
|
||||
? "Добавьте правило: BGP community → GRE-шлюз"
|
||||
: "Сначала добавьте GRE-туннели для этого сервера"
|
||||
}
|
||||
action={
|
||||
(isLive ? allTunnels.length > 0 : allTunnels.some(t => t.serverId === selectedServerId)) ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
className="py-20"
|
||||
/>
|
||||
) : filteredRules.length === 0 ? (
|
||||
/* no search results */
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
@@ -2085,80 +1858,33 @@ export default function FiltersPage() {
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* table header with sort */}
|
||||
<div className={cn(
|
||||
"grid items-center gap-3 px-4 py-1.5 border-b bg-muted/30",
|
||||
"grid-cols-[20px_20px_22px_1fr_1fr_1fr_64px]",
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}>
|
||||
<span>#</span>
|
||||
<span />
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-help text-center font-mono normal-case tracking-normal border-0 bg-transparent p-0 w-full">
|
||||
MT
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
Совпадение с MikroTik (bgp-in): нажмите «Сверить с роутером»
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button onClick={() => toggleSort("community")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Community <SortIndicator k="community" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<button onClick={() => toggleSort("gateway")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Gateway <SortIndicator k="gateway" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<button onClick={() => toggleSort("description")}
|
||||
className="flex items-center gap-1 hover:text-foreground text-left transition-colors">
|
||||
Описание <SortIndicator k="description" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</button>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{(search ? filteredRules : currentRules).map((rule, i, arr) => (
|
||||
<FilterRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
index={i}
|
||||
isLast={i === arr.length - 1}
|
||||
tunnelsList={allTunnels}
|
||||
serversList={allServers}
|
||||
communityNameMap={communityNameMap}
|
||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||
routerSyncStatus={
|
||||
!isLive
|
||||
? undefined
|
||||
: !routerCompare || routerCompare.serverId !== selectedServerId
|
||||
? null
|
||||
: routerCompare.byCommunity[rule.community.trim()] ?? null
|
||||
}
|
||||
onEdit={() => openEdit(rule)}
|
||||
onDelete={() => handleDelete(rule.id)}
|
||||
onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))}
|
||||
onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* footer hint */}
|
||||
<div className="px-4 py-2 text-[11px] text-muted-foreground/40 flex items-center gap-1.5 border-t">
|
||||
<StarIcon className="size-3 text-amber-400 fill-amber-400 shrink-0" />
|
||||
Последнее правило имеет наивысший приоритет в RouterOS
|
||||
</div>
|
||||
</>
|
||||
<FiltersDataGrid
|
||||
rules={search ? filteredRules : currentRules}
|
||||
tunnelsList={allTunnels}
|
||||
serversList={allServers}
|
||||
communityNameMap={communityNameMap}
|
||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||
routerSyncByCommunity={
|
||||
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
|
||||
? null
|
||||
: routerCompare.byCommunity
|
||||
}
|
||||
isLive={isLive}
|
||||
enableSorting={!!search}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onMoveUp={(id) => handleMoveUp(currentRules.findIndex((r) => r.id === id))}
|
||||
onMoveDown={(id) => handleMoveDown(currentRules.findIndex((r) => r.id === id))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* add rule shortcut */}
|
||||
<button onClick={openCreate}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить правило для {selectedServer.name}
|
||||
</button>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+106
-300
@@ -2,10 +2,24 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
FirewallRulesDataGrid,
|
||||
ActionBadge,
|
||||
ChainBadge,
|
||||
} from "@/components/data-grids/firewall-rules-data-grid"
|
||||
import { FirewallScenarioRulesDataGrid } from "@/components/data-grids/firewall-scenario-rules-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
@@ -341,62 +355,7 @@ function fmtHits(n: number): string {
|
||||
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 }) {
|
||||
const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono font-medium px-2 py-0.5 rounded border whitespace-nowrap", cls)}>
|
||||
{action}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ChainBadge({ chain }: { chain: string }) {
|
||||
const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground"
|
||||
return (
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded", cls)}>
|
||||
{chain}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
// ActionBadge, ChainBadge — из firewall-rules-data-grid
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
@@ -417,7 +376,7 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
|
||||
function generateRsc(rules: FirewallRule[], timestamp?: string): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`# RouterLists Firewall Export`)
|
||||
lines.push(`# MikrotikManager Firewall Export`)
|
||||
if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`)
|
||||
lines.push(`# Правил: ${rules.length}`)
|
||||
lines.push("")
|
||||
@@ -507,67 +466,67 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Цепочка и действие</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Цепочка" required>
|
||||
<FormField label="Цепочка" required>
|
||||
<NativeSelect value={form.chain} onChange={(v) => set("chain", v)}>
|
||||
{chainsForGroup.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Действие" required>
|
||||
</FormField>
|
||||
<FormField label="Действие" required>
|
||||
<NativeSelect value={form.action} onChange={(v) => set("action", v)}>
|
||||
{actions.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matching */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Условие совпадения</SectionTitle>
|
||||
<Field label="Протокол">
|
||||
<FormField label="Протокол">
|
||||
<NativeSelect value={form.proto} onChange={(v) => set("proto", v)}>
|
||||
{["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) =>
|
||||
<option key={p} value={p}>{p}</option>
|
||||
)}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<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"
|
||||
value={form.srcAddrList} onChange={(e) => set("srcAddrList", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dst-address / Address-list">
|
||||
</FormField>
|
||||
<FormField label="Dst-address / Address-list">
|
||||
<Input className="font-mono h-8" placeholder="0.0.0.0/0"
|
||||
value={form.dstAddrList} onChange={(e) => set("dstAddrList", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<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="—"
|
||||
value={form.srcPort} onChange={(e) => set("srcPort", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dst-port">
|
||||
</FormField>
|
||||
<FormField label="Dst-port">
|
||||
<Input className="font-mono h-8" placeholder="443"
|
||||
value={form.dstPort} onChange={(e) => set("dstPort", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
value={form.inIface} onChange={(e) => set("inIface", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Out-interface">
|
||||
</FormField>
|
||||
<FormField label="Out-interface">
|
||||
<Input className="font-mono h-8" placeholder="lan"
|
||||
value={form.outIface} onChange={(e) => set("outIface", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</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"
|
||||
value={form.connState} onChange={(e) => set("connState", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Log + Comment */}
|
||||
@@ -578,18 +537,18 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
||||
<p className="text-sm font-medium">Log</p>
|
||||
<p className="text-xs text-muted-foreground">Записывать совпадения в системный лог</p>
|
||||
</div>
|
||||
<Toggle checked={form.log} onChange={(v) => set("log", v)} />
|
||||
<FormToggle checked={form.log} onChange={(v) => set("log", v)} />
|
||||
</div>
|
||||
{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"
|
||||
value={form.logPrefix} onChange={(e) => set("logPrefix", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
<Field label="Комментарий">
|
||||
<FormField label="Комментарий">
|
||||
<Input className="h-8" placeholder="Описание правила"
|
||||
value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
@@ -598,7 +557,7 @@ function RuleSheet({ open, onClose, initialRule, chainGroup }: {
|
||||
<p className="text-sm font-medium">Правило включено</p>
|
||||
<p className="text-xs text-muted-foreground">Отключённые правила сохраняются, но не применяются</p>
|
||||
</div>
|
||||
<Toggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
|
||||
{/* CLI preview */}
|
||||
@@ -1039,14 +998,14 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionTitle>Название</SectionTitle>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Название сценария" required>
|
||||
<FormField label="Название сценария" required>
|
||||
<Input className="h-8" placeholder="Блокировка Tor Exit"
|
||||
value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Описание">
|
||||
</FormField>
|
||||
<FormField label="Описание">
|
||||
<Input className="h-8" placeholder="Краткое описание"
|
||||
value={desc} onChange={e => setDesc(e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1054,7 +1013,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionTitle>Тестовый пакет по умолчанию</SectionTitle>
|
||||
<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)}>
|
||||
<optgroup label="Полный маршрут">
|
||||
<option value="forward">forward — транзит</option>
|
||||
@@ -1067,40 +1026,40 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</optgroup>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Протокол">
|
||||
</FormField>
|
||||
<FormField label="Протокол">
|
||||
<NativeSelect value={pkt.proto} onChange={v => setP("proto", v)}>
|
||||
{PROTO_OPTS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Conn-state">
|
||||
</FormField>
|
||||
<FormField label="Conn-state">
|
||||
<Input className="font-mono h-8" value={pkt.connState}
|
||||
placeholder="new" onChange={e => setP("connState", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Src IP">
|
||||
</FormField>
|
||||
<FormField label="Src IP">
|
||||
<Input className="font-mono h-8" value={pkt.srcAddr}
|
||||
onChange={e => setP("srcAddr", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dst IP">
|
||||
</FormField>
|
||||
<FormField label="Dst IP">
|
||||
<Input className="font-mono h-8" value={pkt.dstAddr}
|
||||
onChange={e => setP("dstAddr", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dst Port">
|
||||
</FormField>
|
||||
<FormField label="Dst Port">
|
||||
<Input className="font-mono h-8" value={pkt.dstPort}
|
||||
placeholder="443" onChange={e => setP("dstPort", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="In-interface">
|
||||
</FormField>
|
||||
<FormField label="In-interface">
|
||||
<Input className="font-mono h-8" value={pkt.inIface}
|
||||
placeholder="lan" onChange={e => setP("inIface", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Out-interface">
|
||||
</FormField>
|
||||
<FormField label="Out-interface">
|
||||
<Input className="font-mono h-8" value={pkt.outIface}
|
||||
placeholder="wan-msk" onChange={e => setP("outIface", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dst addr-list">
|
||||
</FormField>
|
||||
<FormField label="Dst addr-list">
|
||||
<Input className="font-mono h-8" value={pkt.dstAddrList}
|
||||
placeholder="" onChange={e => setP("dstAddrList", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1170,7 +1129,7 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<Button size="sm" onClick={addRule}><PlusIcon className="size-4" />Добавить</Button>
|
||||
@@ -1180,57 +1139,13 @@ function ScenarioSheet({ open, onClose, initial, onSave }: {
|
||||
|
||||
{/* Rules table */}
|
||||
{rules.length > 0 ? (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-muted-foreground">
|
||||
<th className="text-left px-3 py-2 w-6">#</th>
|
||||
<th className="text-left px-3 py-2">Цепочка</th>
|
||||
<th className="text-left px-3 py-2">Действие</th>
|
||||
<th className="text-left px-3 py-2">Src</th>
|
||||
<th className="text-left px-3 py-2">Dst</th>
|
||||
<th className="text-left px-3 py-2">Порт</th>
|
||||
<th className="text-left px-3 py-2">Iface</th>
|
||||
<th className="text-left px-3 py-2">Комментарий</th>
|
||||
<th className="w-24 px-2 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r.id} className={cn(
|
||||
"hover:bg-muted/20 transition-colors",
|
||||
!r.enabled && "opacity-40",
|
||||
)}>
|
||||
<td className="px-3 py-1.5 text-muted-foreground tabular-nums">{i + 1}</td>
|
||||
<td className="px-3 py-1.5"><ChainBadge chain={r.chain} /></td>
|
||||
<td className="px-3 py-1.5"><ActionBadge action={r.action} /></td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.src || "any"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground max-w-[90px] truncate">{r.dst || "any"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.port || "—"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-muted-foreground">{r.iface || "—"}</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground/70 max-w-[110px] truncate">{r.comment || "—"}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex items-center gap-0.5 justify-end">
|
||||
<button type="button" onClick={() => toggleEnabled(r.id)}
|
||||
title={r.enabled ? "Отключить" : "Включить"}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors">
|
||||
<PowerIcon className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => moveRule(r.id, -1)} disabled={i === 0}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▲</button>
|
||||
<button type="button" onClick={() => moveRule(r.id, 1)} disabled={i === rules.length - 1}
|
||||
className="p-0.5 text-muted-foreground/40 hover:text-foreground disabled:opacity-20 transition-colors">▼</button>
|
||||
<button type="button" onClick={() => removeRule(r.id)}
|
||||
className="p-0.5 ml-0.5 text-muted-foreground/40 hover:text-red-500 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<FirewallScenarioRulesDataGrid
|
||||
rules={rules}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
onMoveUp={(id) => moveRule(id, -1)}
|
||||
onMoveDown={(id) => moveRule(id, 1)}
|
||||
onRemove={removeRule}
|
||||
/>
|
||||
) : (
|
||||
!addOpen && (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground border rounded-lg border-dashed">
|
||||
@@ -1721,104 +1636,6 @@ function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Rules Table ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RulesTable({ rules, onToggle, onEdit }: {
|
||||
rules: FirewallRule[]
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (r: FirewallRule) => void
|
||||
}) {
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldOffIcon className="size-10 mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">Правила не найдены</p>
|
||||
<p className="text-xs mt-1">Попробуйте изменить фильтр или добавьте новое правило</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3 w-8">#</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="text-left font-medium px-4 py-3">Порт</th>
|
||||
<th className="text-left font-medium px-4 py-3">Интерфейс</th>
|
||||
<th className="text-right font-medium px-4 py-3">Пакетов</th>
|
||||
<th className="text-left font-medium px-4 py-3 w-12">Вкл</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r.id}
|
||||
className={cn("hover:bg-muted/40 transition-colors", !r.enabled && "opacity-40")}>
|
||||
<td className="px-5 py-2.5 font-mono text-xs text-muted-foreground">{i + 1}</td>
|
||||
<td className="px-4 py-2.5"><ChainBadge chain={r.chain} /></td>
|
||||
<td className="px-4 py-2.5"><ActionBadge action={r.action} /></td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||||
{r.src || "any"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground max-w-[140px] truncate">
|
||||
{r.dst || "any"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs font-mono">{r.proto}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.port || "—"}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.iface || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className={cn(
|
||||
"text-xs font-mono tabular-nums",
|
||||
r.hits > 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold"
|
||||
: r.hits > 10_000 ? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}>
|
||||
{fmtHits(r.hits)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(r)}>
|
||||
<PencilIcon className="size-4" />Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CopyIcon className="size-4" />Дублировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onToggle(r.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />Удалить правило
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function FirewallPage() {
|
||||
const [rules, setRules] = useState<FirewallRule[]>(firewallRules)
|
||||
@@ -1970,56 +1787,45 @@ export default function FirewallPage() {
|
||||
) : chainGroup === "simulator" ? (
|
||||
<SimulatorTab rules={rules} />
|
||||
) : (
|
||||
<Card>
|
||||
{/* toolbar */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
{/* IP family selector */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "ip", "ip6"] as IpFamily[]).map((f) => (
|
||||
<button key={f} onClick={() => setIpFamily(f)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors whitespace-nowrap",
|
||||
ipFamily === f
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{IP_FAMILY_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* chain sub-tabs */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => (
|
||||
<button key={t.value} onClick={() => setChainFilter(t.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-3 py-1 text-xs transition-colors",
|
||||
chainFilter === t.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{t.label}
|
||||
<span className="opacity-50">{chainCounts[t.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* search */}
|
||||
<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="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
<DataPageCard>
|
||||
<DataPageToolbarFrame>
|
||||
<SegmentedControl
|
||||
value={ipFamily}
|
||||
onChange={setIpFamily}
|
||||
options={(["all", "ip", "ip6"] as IpFamily[]).map((f) => ({
|
||||
value: f,
|
||||
label: IP_FAMILY_LABELS[f],
|
||||
}))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={chainFilter}
|
||||
onChange={setChainFilter}
|
||||
options={[
|
||||
{ value: "all", label: "Все", count: chainCounts.all ?? 0 },
|
||||
...chainsInGroup.map((c) => ({
|
||||
value: c,
|
||||
label: c,
|
||||
count: chainCounts[c] ?? 0,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<InputGroup className="min-w-[220px] max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Поиск по адресу, действию…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredRules.length} правил
|
||||
</span>
|
||||
</DataPageToolbarFrame>
|
||||
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filteredRules.length} правил</span>
|
||||
</div>
|
||||
|
||||
<RulesTable rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||
</Card>
|
||||
<FirewallRulesDataGrid rules={filteredRules} onToggle={toggleRule} onEdit={openEdit} />
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
|
||||
+85
-285
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar"
|
||||
import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid"
|
||||
import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
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 { useDataSource } from "@/lib/data-source"
|
||||
@@ -21,7 +26,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
@@ -54,7 +59,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
||||
const srv = serverById[t.serverId]
|
||||
|
||||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||||
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
|
||||
lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`)
|
||||
lines.push("")
|
||||
|
||||
// GRE interface
|
||||
@@ -138,58 +143,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) ─────────────────────────────────────────────
|
||||
|
||||
interface BackendServer {
|
||||
@@ -272,8 +225,8 @@ type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
@@ -501,196 +454,43 @@ export default function GrePage() {
|
||||
|
||||
{/* ── Tunnels ── */}
|
||||
{pageTab === "tunnels" && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{tunnelTabs.map((t) => (
|
||||
<button key={t.value} onClick={() => setTabFilter(t.value)}
|
||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === 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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: tabFilter,
|
||||
onChange: setTabFilter,
|
||||
options: tunnelTabs.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
count: t.count,
|
||||
})),
|
||||
}}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, IP…"
|
||||
countLabel={`${filtered.length} туннелей`}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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">Внутренний IP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Пул</th>
|
||||
<th className="text-left font-medium px-4 py-3">IPsec</th>
|
||||
<th className="text-left font-medium px-4 py-3">Шифрование</th>
|
||||
<th className="text-center font-medium px-4 py-3">MTU</th>
|
||||
<th className="text-left font-medium px-4 py-3">Keepalive</th>
|
||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||||
<th className="w-20 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t, index) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={`${t.id}:${t.serverId}:${t.name}:${index}`} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
|
||||
<td className="px-4 py-3">
|
||||
{t.ipsec ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
|
||||
</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</td>
|
||||
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
|
||||
|
||||
{/* actions */}
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{/* Code preview button */}
|
||||
<Button
|
||||
variant="ghost" size="icon" className="size-7"
|
||||
title="Предпросмотр кода RouterOS"
|
||||
onClick={() => setCodePreviewTunnel(t)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<GreTunnelsDataGrid
|
||||
tunnels={filtered}
|
||||
servers={displayServers}
|
||||
pools={displayPools}
|
||||
onCodePreview={setCodePreviewTunnel}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* ── IP Pools ── */}
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<DataPageCard>
|
||||
<DataPageToolbarFrame className="justify-between">
|
||||
<span className="text-sm text-muted-foreground">{displayPools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пул
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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">Диапазон CIDR</th>
|
||||
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
|
||||
<th className="text-right font-medium px-4 py-3">Доступно /30</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-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{displayPools.map((pool) => {
|
||||
const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
|
||||
<td className="px-3 py-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DataPageToolbarFrame>
|
||||
<GrePoolsDataGrid pools={displayPools} />
|
||||
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
@@ -715,7 +515,7 @@ export default function GrePage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
@@ -838,51 +638,51 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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)} />
|
||||
</Field>
|
||||
<Field label="Сервер (MikroTik)" required>
|
||||
</FormField>
|
||||
<FormField label="Сервер (MikroTik)" required>
|
||||
<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">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
</FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="flex flex-col gap-4">
|
||||
<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)} />
|
||||
</Field>
|
||||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||||
</FormField>
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<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)}
|
||||
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>
|
||||
{displayPools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</FormField>
|
||||
<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)} />
|
||||
</Field>
|
||||
<Field label="Удалённый IP" required hint="/ip address на другом конце">
|
||||
</FormField>
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -893,12 +693,12 @@ export default function GrePage() {
|
||||
<p className="text-sm font-medium">Включить IPsec</p>
|
||||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||
</div>
|
||||
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||
<FormToggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||
</div>
|
||||
|
||||
{tForm.ipsecEnabled && (
|
||||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||||
<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">
|
||||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||||
@@ -907,38 +707,38 @@ export default function GrePage() {
|
||||
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="IKE-версия">
|
||||
</FormField>
|
||||
<FormField label="IKE-версия">
|
||||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||||
</Field>
|
||||
</FormField>
|
||||
<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)}
|
||||
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>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Хеш-алгоритм">
|
||||
</FormField>
|
||||
<FormField label="Хеш-алгоритм">
|
||||
<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">
|
||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<FormField label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<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">
|
||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</FormField>
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<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>
|
||||
@@ -955,23 +755,23 @@ export default function GrePage() {
|
||||
{tForm.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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))} />
|
||||
</Field>
|
||||
<Field label="Keepalive, с" hint="0 = откл.">
|
||||
</FormField>
|
||||
<FormField label="Keepalive, с" hint="0 = откл.">
|
||||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Попытки">
|
||||
</FormField>
|
||||
<FormField label="Попытки">
|
||||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="DSCP">
|
||||
<FormField label="DSCP">
|
||||
<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">
|
||||
<option value="inherit">inherit</option>
|
||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</FormField>
|
||||
{[
|
||||
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
||||
@@ -981,7 +781,7 @@ export default function GrePage() {
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</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>
|
||||
@@ -1006,18 +806,18 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||
</FormField>
|
||||
<FormField label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Назначение / Комментарий">
|
||||
</FormField>
|
||||
<FormField label="Назначение / Комментарий">
|
||||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
@@ -23,13 +28,27 @@ export default function IpRangesPage() {
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.cidr.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.country.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||
actions={
|
||||
<>
|
||||
<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 size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||
</>
|
||||
@@ -56,58 +75,31 @@ export default function IpRangesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "cidr",
|
||||
label: "CIDR",
|
||||
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Страна",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
countLabel={`${filtered.length} диапазонов`}
|
||||
/>
|
||||
<IpRangesDataGrid
|
||||
ipRanges={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт IP-диапазонов"
|
||||
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||
import { DataSourceProvider } from "@/lib/data-source"
|
||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||
|
||||
@@ -12,6 +13,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
|
||||
@@ -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() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const useLiveData = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const useLiveData = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [mapServers, setMapServers] = useState<Server[]>(mockServers)
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>(mockGreTunnels)
|
||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
|
||||
+31
-153
@@ -2,6 +2,10 @@
|
||||
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
|
||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@@ -375,13 +379,6 @@ function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
|
||||
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
|
||||
}
|
||||
|
||||
function routeTypeClass(type: OspfRoute["type"]) {
|
||||
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
|
||||
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
|
||||
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
|
||||
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
|
||||
}
|
||||
|
||||
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
@@ -978,60 +975,14 @@ function NeighborsTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{neighbors.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Нет OSPF-соседей
|
||||
</td>
|
||||
</tr>
|
||||
) : neighbors.map(n => {
|
||||
const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter
|
||||
return (
|
||||
<tr key={n.id}
|
||||
onMouseEnter={() => setHighlightId(n.localRouter)}
|
||||
onMouseLeave={() => setHighlightId(null)}
|
||||
onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)}
|
||||
className={cn(
|
||||
"transition-colors cursor-pointer",
|
||||
isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30",
|
||||
)}>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{n.localLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{n.localIface}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono">{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}</span>
|
||||
{n.remoteLabel !== n.remoteRouterId && (
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{n.area}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(n.state))}>
|
||||
{n.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{n.cost}</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">{n.uptime}</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono">{n.priority}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfNeighborsDataGrid
|
||||
neighbors={neighbors}
|
||||
selectedRouterId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onHighlight={setHighlightId}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1052,36 +1003,9 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{routes.map(r => (
|
||||
<tr key={r.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono">{r.destination}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", routeTypeClass(r.type))}>
|
||||
{r.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{r.cost}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.nextHop}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.via}</td>
|
||||
<td className="px-3 py-2.5 font-mono">{r.serverLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.area}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfRoutesDataGrid routes={routes} />
|
||||
</DataPageCard>
|
||||
<div className="flex items-center gap-5 flex-wrap px-1">
|
||||
<span className="text-xs text-muted-foreground">Типы:</span>
|
||||
{([
|
||||
@@ -1139,66 +1063,9 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
{[
|
||||
"Роутер", "Интерфейс", "Локальный", "Удалённый",
|
||||
"Состояние", "Uptime", "Tx / Rx", "Hold", "Mult",
|
||||
"Пакеты Rx", "Пакеты Tx", "Переходы",
|
||||
].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{sessions.map(b => (
|
||||
<tr key={b.id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{b.serverLabel}</span>
|
||||
{b.multihop && (
|
||||
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">multihop</Chip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{b.iface || "—"}</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.localAddr}</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.remoteAddr}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(b.state))}>
|
||||
{b.state}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">
|
||||
{b.uptime ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
|
||||
{fmtMs(b.holdTime)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{b.multiplier}</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
|
||||
{b.packetsRx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
|
||||
{b.packetsTx.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
|
||||
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
|
||||
{b.stateChanges}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<OspfBfdDataGrid sessions={sessions} />
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
@@ -1227,8 +1094,8 @@ export default function OspfPage() {
|
||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -1291,6 +1158,16 @@ export default function OspfPage() {
|
||||
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
||||
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
||||
}
|
||||
if (isLive) {
|
||||
return {
|
||||
items: [],
|
||||
neighbors: [],
|
||||
graphNodes: [],
|
||||
graphEdges: [],
|
||||
routerIds: {},
|
||||
bfdSessions: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: MOCK_ITEMS,
|
||||
neighbors: MOCK_NEIGHBORS,
|
||||
@@ -1318,6 +1195,7 @@ export default function OspfPage() {
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
if (isLive) return []
|
||||
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
|
||||
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
|
||||
const siteCountry: Record<string, string> = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
|
||||
|
||||
+32
-139
@@ -2,7 +2,18 @@
|
||||
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
ProbesScheduleDataGrid,
|
||||
type SchedRule,
|
||||
type SchedType,
|
||||
} from "@/components/data-grids/probes-schedule-data-grid"
|
||||
import {
|
||||
ProbesSpeedProbesDataGrid,
|
||||
type SpeedProbeApiRow,
|
||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||
@@ -40,13 +51,8 @@ interface DiagTest {
|
||||
source?: "demo" | "live"
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
// SchedRule imported from probes-schedule-data-grid
|
||||
|
||||
interface SchedRule {
|
||||
id: string; srcId: string; tunnelId: string; type: SchedType
|
||||
intervalMin: number; enabled: boolean
|
||||
lastRun: string | null; nextRunMin: number | null
|
||||
}
|
||||
|
||||
// ─── tool metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -97,22 +103,7 @@ interface BackendServerRow {
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
interface SpeedProbeApiRow {
|
||||
id: string
|
||||
srcServerId: string
|
||||
dstServerId: string
|
||||
srcInterface: string
|
||||
dstInterface: string
|
||||
protocol: string
|
||||
direction: string
|
||||
durationSec: string
|
||||
enabled: boolean
|
||||
lastRunAt: string | null
|
||||
lastTxAvgMbps: number | null
|
||||
lastRxAvgMbps: number | null
|
||||
lastStatus: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
// SpeedProbeApiRow imported from probes-speed-probes-data-grid
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -353,17 +344,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 }) {
|
||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||
}
|
||||
@@ -476,53 +456,9 @@ function ScheduleSpeedProbesLive({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет записей speed-test в мониторинге</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-md text-center">
|
||||
Настраиваются через API <code className="text-[11px]">PUT /api/uptime/speed-probes</code> или связанный UI.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span>Источник</span>
|
||||
<span>Назначение</span>
|
||||
<span>Протокол</span>
|
||||
<span>Сек</span>
|
||||
<span>Вкл</span>
|
||||
<span>Последний запуск</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className={cn(
|
||||
"grid grid-cols-[1fr_1fr_80px_90px_80px_1fr] gap-2 items-center px-4 py-2.5 text-xs",
|
||||
!r.enabled && "opacity-50",
|
||||
)}>
|
||||
<span className="truncate font-mono">{name(r.srcServerId)}{r.srcInterface ? ` · ${r.srcInterface}` : ""}</span>
|
||||
<span className="truncate font-mono">{name(r.dstServerId)}{r.dstInterface ? ` · ${r.dstInterface}` : ""}</span>
|
||||
<span>{r.protocol.toUpperCase()}</span>
|
||||
<span className="font-mono">{r.durationSec}s</span>
|
||||
<span>{r.enabled ? "да" : "нет"}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{r.lastRunAt ?? "—"}
|
||||
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
|
||||
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
|
||||
</span>
|
||||
)}
|
||||
{r.lastStatus === "error" && r.lastError && (
|
||||
<span className="text-destructive ml-1 truncate">{r.lastError}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
|
||||
</DataPageCard>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
|
||||
</p>
|
||||
@@ -563,63 +499,22 @@ function ScheduleTab({
|
||||
}
|
||||
}, [addSrc, addTun, tunnelsForServer])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
const tunnelName = (srcId: string, tunnelId: string) =>
|
||||
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет правил расписания</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span />
|
||||
<span>Туннель</span>
|
||||
<span>Сервер</span>
|
||||
<span>Тип</span>
|
||||
<span>Интервал</span>
|
||||
<span>Последний / следующий</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rules.map(rule => {
|
||||
const src = serverOptions.find(s => s.id === rule.srcId)
|
||||
const tun = tunnelsForServer(rule.srcId).find(t => t.id === rule.tunnelId)
|
||||
return (
|
||||
<div key={rule.id} className={cn(
|
||||
"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",
|
||||
)}>
|
||||
<Toggle checked={rule.enabled}
|
||||
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>
|
||||
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}>{typeLabel[rule.type]}</span>
|
||||
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<ProbesScheduleDataGrid
|
||||
rules={rules}
|
||||
serverOptions={serverOptions}
|
||||
tunnelName={tunnelName}
|
||||
onToggleEnabled={(id, enabled) =>
|
||||
setRules((p) => p.map((r) => (r.id === id ? { ...r, enabled } : r)))
|
||||
}
|
||||
onDelete={(id) => setRules((p) => p.filter((r) => r.id !== id))}
|
||||
/>
|
||||
</DataPageCard>
|
||||
{showAdd ? (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||
@@ -729,10 +624,8 @@ export default function ProbesPage() {
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
if (liveServers.length > 0) return liveServers
|
||||
if (liveLoad === "error") return servers
|
||||
return []
|
||||
}, [isLive, liveServers, liveLoad])
|
||||
return liveServers
|
||||
}, [isLive, liveServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
@@ -1117,7 +1010,7 @@ export default function ProbesPage() {
|
||||
Как в RouterOS: резолвить IP промежуточных узлов в DNS-имена на самом MikroTik.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
<FormToggle checked={traceUseDns} onChange={setTraceUseDns} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
RecursiveRoutesDataGrid,
|
||||
type RecursiveRouteGroup,
|
||||
inferCountry,
|
||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -11,7 +18,7 @@ import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
interface BackendServer {
|
||||
@@ -34,25 +41,6 @@ interface GatewayOption {
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some(k => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||
@@ -82,13 +70,7 @@ interface RecursiveRouteRow {
|
||||
country: string
|
||||
}
|
||||
|
||||
interface RouteGroup {
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteRow[]
|
||||
}
|
||||
interface RouteGroup extends RecursiveRouteGroup {}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -148,131 +130,6 @@ const emptyForm = (): RouteForm => ({
|
||||
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({
|
||||
group, expanded, onToggle, onEdit, onDelete,
|
||||
}: {
|
||||
group: RouteGroup
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
|
||||
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={cn(
|
||||
"hover:bg-muted/40 transition-colors cursor-pointer group",
|
||||
expanded && "bg-muted/30",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{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">{group.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.map((ep, idx) => {
|
||||
const code = ep.country || inferCountry(ep.gateway)
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
|
||||
)} />
|
||||
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
|
||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-muted/20">
|
||||
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
|
||||
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(ep.country || inferCountry(ep.gateway)) && (
|
||||
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [query, setQuery] = useState("")
|
||||
const q = query.trim().toUpperCase()
|
||||
@@ -370,9 +227,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 flex-col gap-4">
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -396,26 +253,26 @@ function RouteSheet({
|
||||
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<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)} />
|
||||
</Field>
|
||||
<Field label="Check Gateway">
|
||||
</FormField>
|
||||
<FormField label="Check Gateway">
|
||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<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)} />
|
||||
</Field>
|
||||
<Field label="T.Scope">
|
||||
</FormField>
|
||||
<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)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||
@@ -467,10 +324,10 @@ function RouteSheet({
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Параметры</SectionTitle>
|
||||
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
|
||||
<Field label="Комментарий">
|
||||
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</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>
|
||||
@@ -498,12 +355,12 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
||||
}
|
||||
|
||||
export default function RecursiveRoutesPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Server[]>(mockServers)
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
@@ -798,9 +655,9 @@ export default function RecursiveRoutesPage() {
|
||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
<DataPageCard>
|
||||
{currentServer && (
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
||||
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||
<Flag code={currentServer.country} size={16} />
|
||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||
@@ -819,43 +676,19 @@ export default function RecursiveRoutesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
|
||||
<th className="text-left font-medium px-4 py-3">Gateways</th>
|
||||
<th className="text-left font-medium px-4 py-3">EP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Priority</th>
|
||||
<th className="text-left font-medium px-4 py-3">Table</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedRoutes.map((g) => (
|
||||
<RouteGroupRows
|
||||
key={g.key}
|
||||
group={g}
|
||||
expanded={expandedGroupKey === g.key}
|
||||
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
|
||||
onEdit={() => openEdit(g)}
|
||||
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{groupedRoutes.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RecursiveRoutesDataGrid
|
||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||
expandedKey={expandedGroupKey}
|
||||
onExpandedChange={setExpandedGroupKey}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
||||
/>
|
||||
<button onClick={openCreate}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
</Card>
|
||||
</DataPageCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
|
||||
import {
|
||||
formatCommitSubject,
|
||||
getReleaseCommitPreview,
|
||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
||||
} from "@/lib/release-commits"
|
||||
import { readReleaseManifest } from "@/lib/release-manifest"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ExternalLinkIcon } from "lucide-react"
|
||||
@@ -24,7 +29,7 @@ export default function ReleasesPage() {
|
||||
const version = getAppVersion()
|
||||
const isProdBuild = !version.endsWith("-dev")
|
||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
||||
const commits = manifest.commits
|
||||
const commits = getReleaseCommitPreview(manifest.commits)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -50,7 +55,7 @@ export default function ReleasesPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Текущая версия</CardTitle>
|
||||
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
|
||||
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -68,7 +73,7 @@ export default function ReleasesPage() {
|
||||
<CardTitle>Сводка коммитов</CardTitle>
|
||||
<CardDescription>
|
||||
{commits.length > 0
|
||||
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
|
||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -84,7 +89,7 @@ export default function ReleasesPage() {
|
||||
<span className="font-mono">{commit.shortSha}</span>
|
||||
<span>{commit.author}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{commit.subject}</p>
|
||||
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
||||
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
|
||||
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
|
||||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -245,17 +251,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 }) {
|
||||
return (
|
||||
<Input type="number" value={value} min={min} max={max}
|
||||
@@ -276,327 +271,6 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
|
||||
)
|
||||
}
|
||||
|
||||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||||
|
||||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
pw: number
|
||||
}) {
|
||||
// find best leg overall
|
||||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||||
{jumpHosts.map(jh => (
|
||||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||||
<div>{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
<Flag code={jh.country} />
|
||||
{jh.site} · {jh.ip}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{home.wans.map(wan => (
|
||||
<tr key={wan.id} className="hover:bg-muted/30 transition-colors">
|
||||
{/* WAN name */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="font-mono text-xs font-semibold">{wan.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* ISP */}
|
||||
<td className="px-3 py-3">
|
||||
<p className="text-xs font-medium">{wan.isp}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">{wan.ip}</p>
|
||||
</td>
|
||||
{/* Max bandwidth */}
|
||||
<td className="px-3 py-3 text-right">
|
||||
<p className="font-mono text-xs">↓{wan.maxDl}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</td>
|
||||
{/* Per-JH cells */}
|
||||
{jumpHosts.map(jh => {
|
||||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||||
const isBest = leg.score === bestScore
|
||||
return (
|
||||
<td key={jh.id} className={cn(
|
||||
"px-3 py-3 text-center",
|
||||
isBest && "bg-emerald-500/5",
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
|
||||
isBest
|
||||
? "border border-emerald-500/20 bg-emerald-500/8"
|
||||
: "border border-transparent",
|
||||
)}>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
|
||||
★ ЛУЧШИЙ
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("font-mono text-xs font-semibold",
|
||||
leg.pingMs < 10 ? "text-emerald-600 dark:text-emerald-400"
|
||||
: leg.pingMs < 25 ? "text-foreground"
|
||||
: "text-amber-600 dark:text-amber-400"
|
||||
)}>
|
||||
{leg.pingMs} мс
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
↓{leg.dlMbps} ↑{leg.ulMbps}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] font-mono text-foreground/70">
|
||||
score {leg.score}
|
||||
</span>
|
||||
{leg.loss > 0 && <LossChip loss={leg.loss} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Full routes table ────────────────────────────────────────────────────────
|
||||
|
||||
function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const visible = expanded ? routes : routes.slice(0, 5)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2"># Маршрут</th>
|
||||
<th className="text-left font-medium px-3 py-2">WAN → JH</th>
|
||||
<th className="text-left font-medium px-3 py-2">JH → Exit</th>
|
||||
<th className="text-center font-medium px-3 py-2">Ping (итого)</th>
|
||||
<th className="text-center font-medium px-3 py-2">BW (мин)</th>
|
||||
<th className="text-center font-medium px-3 py-2">Score</th>
|
||||
<th className="text-center font-medium px-3 py-2">P(opt)</th>
|
||||
<th className="text-center font-medium px-3 py-2">Conf.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{visible.map((r, i) => {
|
||||
const isBest = r.id === bestId || i === 0
|
||||
const totalPing = r.hw.pingMs + r.je.pingMs
|
||||
const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps)
|
||||
const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps)
|
||||
return (
|
||||
<tr key={r.id} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
isBest && "bg-emerald-500/5",
|
||||
)}>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-4">{i + 1}</span>
|
||||
{isBest && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
|
||||
Лучший
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{r.jh.label}</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div>
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<Flag code={r.exit.country} />
|
||||
{r.exit.label}
|
||||
<span className="text-[10px] text-muted-foreground">({r.exit.site})</span>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-muted-foreground">{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={cn("px-3 py-2.5 text-center font-mono text-xs",
|
||||
totalPing < 40 ? "text-emerald-600 dark:text-emerald-400"
|
||||
: totalPing < 80 ? "text-amber-600 dark:text-amber-400"
|
||||
: "text-red-500"
|
||||
)}>
|
||||
{totalPing} мс
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-xs text-muted-foreground">
|
||||
<div>↓{minDl}</div>
|
||||
<div>↑{minUl}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-xs font-semibold">{r.score}</td>
|
||||
<td className="px-3 py-2.5 text-center"><ProbChip prob={r.probabilityOptimal} best={isBest} /></td>
|
||||
<td className="px-3 py-2.5 text-center"><ConfChip conf={r.confidence} /></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{routes.length > 5 && (
|
||||
<button onClick={() => setExpanded(v => !v)}
|
||||
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground transition-colors border-t flex items-center justify-center gap-1">
|
||||
{expanded
|
||||
? <><ChevronUpIcon className="size-3" />Свернуть</>
|
||||
: <><ChevronDownIcon className="size-3" />Показать все {routes.length} комбинаций</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Community recs table ─────────────────────────────────────────────────────
|
||||
|
||||
function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: {
|
||||
recs: CommRec[]
|
||||
homeId: string
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (k: string) => void
|
||||
onApply: (comm: string, homeId: string) => void
|
||||
threshold: number
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/20">
|
||||
<th className="text-left font-medium px-4 py-2">Community</th>
|
||||
<th className="text-left font-medium px-3 py-2">Текущий (WAN → JH → Exit)</th>
|
||||
<th className="text-left font-medium px-3 py-2">Рекомендуемый</th>
|
||||
<th className="text-center font-medium px-3 py-2">P(тек / рек)</th>
|
||||
<th className="text-right font-medium px-3 py-2">Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{recs.map((r, idx) => {
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = applied.has(pinKey)
|
||||
const isApplying = applying.has(pinKey)
|
||||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||
|
||||
return (
|
||||
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
||||
isApplied && "bg-emerald-500/5",
|
||||
)}>
|
||||
{/* community */}
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-mono text-xs font-medium">{r.community}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{r.communityName}</div>
|
||||
</td>
|
||||
|
||||
{/* current route */}
|
||||
<td className="px-3 py-2.5">
|
||||
{r.current ? (
|
||||
<div className="text-xs flex items-center gap-1 flex-wrap">
|
||||
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">{r.current.wan}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span>{r.current.jh}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">{r.current.exit}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">({r.current.gateway})</span>
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
|
||||
{/* recommended */}
|
||||
<td className="px-3 py-2.5">
|
||||
{r.recommended ? (
|
||||
<div className={cn("text-xs flex items-center gap-1 flex-wrap",
|
||||
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400")}>
|
||||
<span className="font-mono font-medium">{r.recommended.wan}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.jh}</span>
|
||||
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
|
||||
<span>{r.recommended.exit}</span>
|
||||
{r.shouldSwitch && !isPinned && (
|
||||
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
|
||||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : <span className="text-muted-foreground text-xs">—</span>}
|
||||
</td>
|
||||
|
||||
{/* probability */}
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ProbChip prob={r.current?.prob ?? 0} />
|
||||
<span className="text-muted-foreground text-[10px]">/</span>
|
||||
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !isPinned} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* actions */}
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
|
||||
<Button variant="outline" size="sm"
|
||||
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
|
||||
onClick={() => onPin(pinKey)}>
|
||||
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
|
||||
{isPinned ? "Открепить" : "Закрепить"}
|
||||
</Button>
|
||||
{canApply && (
|
||||
<Button size="sm" className="h-7 text-xs" disabled={isApplying}
|
||||
onClick={() => onApply(r.community, homeId)}>
|
||||
{isApplying
|
||||
? <RefreshCwIcon className="size-3 animate-spin" />
|
||||
: <PlayIcon className="size-3" />}
|
||||
Применить
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircleIcon className="size-3" />Применено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Home Router card ─────────────────────────────────────────────────────────
|
||||
|
||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||
@@ -679,18 +353,17 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "wan-matrix" && (
|
||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||||
<RouteOptimizerWanMatrixDataGrid home={home} legs={wanJhLegs} jumpHosts={jumpHosts} />
|
||||
)}
|
||||
{tab === "full-routes" && (
|
||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
<RouteOptimizerFullRoutesDataGrid routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
)}
|
||||
{tab === "bgp-community" && (
|
||||
<CommRecsTable
|
||||
<RouteOptimizerCommRecsDataGrid
|
||||
recs={commRecs}
|
||||
homeId={home.id}
|
||||
pinned={pinned} applied={applied} applying={applying}
|
||||
onPin={onPin} onApply={onApply}
|
||||
threshold={settings.switchThreshold}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1167,7 +840,7 @@ export default function RouteOptimizerPage() {
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Применять автоматически</span>
|
||||
<Toggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||||
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
|
||||
</div>
|
||||
{settings.autoApply && (
|
||||
<>
|
||||
@@ -1264,51 +937,14 @@ export default function RouteOptimizerPage() {
|
||||
: "нет данных"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30">
|
||||
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
|
||||
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{ospfPreviewError && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-destructive">
|
||||
Ошибка preview: {ospfPreviewError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
|
||||
Интерфейсы OSPF не найдены для выбранного сервера.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(ospfPreview?.interfaces ?? []).map((row) => (
|
||||
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
|
||||
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
|
||||
<td className="px-3 py-1.5 font-mono">
|
||||
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
|
||||
{" → "}
|
||||
<span className={row.currentCost === row.optimalCost
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-amber-600 dark:text-amber-400"}
|
||||
>
|
||||
{row.optimalCost}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.score}</td>
|
||||
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
|
||||
<td className="px-3 py-1.5 font-mono">↓{row.dlMbps} / ↑{row.ulMbps}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<RouteOptimizerOspfPreviewDataGrid
|
||||
rows={(ospfPreview?.interfaces ?? []).map((row) => ({
|
||||
id: `${row.interface}-${row.currentCost}-${row.optimalCost}`,
|
||||
...row,
|
||||
}))}
|
||||
error={ospfPreviewError || null}
|
||||
loading={ospfPreviewLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ospfApplyResult && (
|
||||
|
||||
+161
-473
@@ -1,8 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
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 type { ServerType, Server, WanUplink } from "@/lib/data"
|
||||
import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers"
|
||||
@@ -23,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -30,56 +36,25 @@ import {
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import {
|
||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
EyeIcon, EyeOffIcon,
|
||||
ChevronRightIcon, ChevronDownIcon,
|
||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||
ShieldIcon, WifiIcon,
|
||||
HomeIcon, ServerIcon, NetworkIcon,
|
||||
} 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const COUNTRIES = [
|
||||
@@ -97,90 +72,8 @@ const COUNTRIES = [
|
||||
{ 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 ──────────────────────────────────────────────────
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
@@ -267,30 +160,30 @@ function WanUplinkEditor({ wans, onChange }: {
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Интерфейс">
|
||||
</FormField>
|
||||
<FormField label="Интерфейс">
|
||||
<Input className="h-8 font-mono text-xs" placeholder="ether1"
|
||||
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Провайдер (ISP)">
|
||||
</FormField>
|
||||
<FormField label="Провайдер (ISP)">
|
||||
<Input className="h-8 text-xs" placeholder="Rostelecom"
|
||||
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Внешний IP">
|
||||
</FormField>
|
||||
<FormField label="Внешний IP">
|
||||
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
|
||||
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="↓ Макс. Мбит">
|
||||
</FormField>
|
||||
<FormField label="↓ Макс. Мбит">
|
||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="↑ Макс. Мбит">
|
||||
</FormField>
|
||||
<FormField label="↑ Макс. Мбит">
|
||||
<Input className="h-8 font-mono text-xs" type="number" min={1}
|
||||
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -321,17 +214,18 @@ type SheetMode = "add" | "edit"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function ServersPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [serverList, setServerList] = useState<Server[]>(initialServers)
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
const [_backendOk, setBackendOk] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [advancedFilters, setAdvancedFilters] = useState<Filter[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<SheetMode>("add")
|
||||
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 [testState, setTestState] = useState<TestState>("idle")
|
||||
const [testMsg, setTestMsg] = useState("")
|
||||
@@ -358,7 +252,7 @@ export default function ServersPage() {
|
||||
})
|
||||
.catch(() => {
|
||||
setBackendOk(false)
|
||||
setServerList(initialServers)
|
||||
setServerList([])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, backendUrl])
|
||||
@@ -368,6 +262,7 @@ export default function ServersPage() {
|
||||
function openAdd() {
|
||||
setSheetMode("add"); setEditingId(null)
|
||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||
setSheetStep(1)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
@@ -381,7 +276,7 @@ export default function ServersPage() {
|
||||
lanSubnet: s.lanSubnet ?? "",
|
||||
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
|
||||
if (isLive) {
|
||||
@@ -549,13 +444,14 @@ export default function ServersPage() {
|
||||
// ── derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return serverList.filter(sv => {
|
||||
const base = serverList.filter(sv => {
|
||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||
})
|
||||
}, [serverList, search, typeFilter])
|
||||
return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||
}, [serverList, search, typeFilter, advancedFilters])
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: serverList.length,
|
||||
@@ -615,273 +511,35 @@ export default function ServersPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.value} onClick={() => setTypeFilter(tab.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
||||
typeFilter === tab.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{tab.label}
|
||||
<span className="text-xs tabular-nums opacity-60">
|
||||
{tab.value === "all" ? counts.all : counts[tab.value as ServerType]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, хосту…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} серверов</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: typeFilter,
|
||||
onChange: setTypeFilter,
|
||||
options: tabs.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||
})),
|
||||
}}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={setAdvancedFilters}
|
||||
filterFields={SERVER_FILTER_FIELDS}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, хосту…"
|
||||
countLabel={`${filtered.length} серверов`}
|
||||
/>
|
||||
<ServersDataGrid
|
||||
servers={filtered}
|
||||
isLive={isLive}
|
||||
pollingIds={pollingIds}
|
||||
onPoll={handlePoll}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -893,18 +551,45 @@ export default function ServersPage() {
|
||||
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
|
||||
</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. Основные */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
|
||||
<Field label="Имя сервера" required hint="Например home-msk-01">
|
||||
<FormField label="Имя сервера" required hint="Например home-msk-01">
|
||||
<Input className="font-mono" placeholder="home-msk-01"
|
||||
value={form.name} onChange={e => set("name", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Тип узла" required>
|
||||
<FormField label="Тип узла" required>
|
||||
<SegmentedControl
|
||||
value={form.type}
|
||||
onChange={v => set("type", v)}
|
||||
@@ -914,24 +599,24 @@ export default function ServersPage() {
|
||||
{ value: "exit-node", label: "Exit Node" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<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"
|
||||
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
|
||||
</Field>
|
||||
</FormField>
|
||||
{!isHomeRouter && (
|
||||
<Field label="ASN" hint="Например AS65001">
|
||||
<FormField label="ASN" hint="Например AS65001">
|
||||
<Input className="font-mono" placeholder="AS65001"
|
||||
value={form.asn} onChange={e => set("asn", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
{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"
|
||||
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -939,45 +624,43 @@ export default function ServersPage() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{/* 2. WAN-аплинки (только для home-router) */}
|
||||
{isHomeRouter && (
|
||||
<div className="flex flex-col gap-4">
|
||||
</StepperContent>
|
||||
<StepperContent value={2} className="flex flex-col gap-4">
|
||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||
{!isHomeRouter ? (
|
||||
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||
) : (
|
||||
<WanUplinkEditor
|
||||
wans={form.wanUplinks}
|
||||
onChange={wans => set("wanUplinks", wans)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Подключение (API) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
)}
|
||||
</StepperContent>
|
||||
<StepperContent value={3} className="flex flex-col gap-4">
|
||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
|
||||
|
||||
<Field label="Хост / IP-адрес" required
|
||||
<FormField label="Хост / IP-адрес" required
|
||||
hint={isHomeRouter
|
||||
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
|
||||
: "Внешний или управляющий IP-адрес роутера"}>
|
||||
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
|
||||
value={form.host} onChange={e => set("host", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Протокол">
|
||||
<FormField label="Протокол">
|
||||
<SegmentedControl
|
||||
value={form.proto}
|
||||
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
|
||||
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Порт" hint="443 / 80">
|
||||
</FormField>
|
||||
<FormField label="Порт" hint="443 / 80">
|
||||
<Input className="font-mono" placeholder="443"
|
||||
value={form.port} onChange={e => set("port", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -988,13 +671,13 @@ export default function ServersPage() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
|
||||
</div>
|
||||
<Toggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
|
||||
</div>
|
||||
|
||||
<Field label="Путь API">
|
||||
<FormField label="Путь API">
|
||||
<Input className="font-mono" placeholder="/rest"
|
||||
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">
|
||||
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
|
||||
@@ -1008,12 +691,12 @@ export default function ServersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
|
||||
<Input className="font-mono" placeholder="api-user"
|
||||
value={form.username} onChange={e => set("username", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Пароль" required>
|
||||
<FormField label="Пароль" required>
|
||||
<div className="relative">
|
||||
<Input type={form.showPassword ? "text" : "password"}
|
||||
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
|
||||
@@ -1024,7 +707,7 @@ export default function ServersPage() {
|
||||
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
|
||||
@@ -1046,10 +729,8 @@ export default function ServersPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Дополнительно */}
|
||||
<div className="flex flex-col gap-4">
|
||||
</StepperContent>
|
||||
<StepperContent value={4} className="flex flex-col gap-4">
|
||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
@@ -1059,34 +740,41 @@ export default function ServersPage() {
|
||||
{form.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="SSH-порт">
|
||||
<FormField label="SSH-порт">
|
||||
<Input type="number" className="font-mono" value={form.sshPort}
|
||||
onChange={e => set("sshPort", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Winbox-порт">
|
||||
</FormField>
|
||||
<FormField label="Winbox-порт">
|
||||
<Input type="number" className="font-mono" value={form.winboxPort}
|
||||
onChange={e => set("winboxPort", Number(e.target.value))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
<Field label="Таймаут соединения, с">
|
||||
<FormField label="Таймаут соединения, с">
|
||||
<Input type="number" className="font-mono" value={form.timeout}
|
||||
onChange={e => set("timeout", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
</FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input placeholder="Описание или заметка" value={form.comment}
|
||||
onChange={e => set("comment", e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
+65
-225
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
@@ -24,6 +26,9 @@ import {
|
||||
ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, LoaderCircleIcon,
|
||||
DownloadIcon, UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { SubusersDataGrid } from "@/components/data-grids/subusers-data-grid"
|
||||
import { SettingsAccessSummaryDataGrid } from "@/components/data-grids/settings-access-summary-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { downloadSystemDatabaseBackup, restoreSystemDatabaseBackup } from "@/shared/api/system-database"
|
||||
@@ -162,17 +167,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
|
||||
// ─── 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 }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
@@ -413,24 +407,24 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
{tab === "profile" && (
|
||||
<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)}
|
||||
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)}
|
||||
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)}
|
||||
placeholder="i.ivanov@company.io" type="email" className="h-9" />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Field label="Роль">
|
||||
<FormField label="Роль">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(["viewer", "operator", "admin"] as Role[]).map(r => (
|
||||
<button key={r} type="button" onClick={() => setRole(r)}
|
||||
@@ -453,18 +447,18 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
? "Управление инфраструктурой согласно выданным правам"
|
||||
: "Только просмотр согласно выданным правам"}
|
||||
</p>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Field label="Статус учётной записи">
|
||||
<FormField label="Статус учётной записи">
|
||||
<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")}>
|
||||
{form.active ? "Активна" : "Заблокирована"}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -577,100 +571,14 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
{form.subUsers.length > 0 && (
|
||||
<div className="grid items-center gap-2 px-4 py-1.5 bg-muted/20 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
|
||||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||||
<span>Логин / описание</span>
|
||||
<span>Пароль</span>
|
||||
<span>JH-серверы</span>
|
||||
<span>IP-клиента</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{form.subUsers.length === 0 && !addSubOpen && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<CableIcon className="size-6 opacity-20" />
|
||||
<p className="text-sm">Нет GRE-клиентов</p>
|
||||
<p className="text-xs opacity-60">Добавьте учётки для подключения устройств</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.subUsers.map(su => {
|
||||
const jhs = servers.filter(s => su.jhServerIds.includes(s.id))
|
||||
const revealed = revealedIds.has(su.id)
|
||||
return (
|
||||
<div key={su.id}
|
||||
className={cn(
|
||||
"grid items-center gap-2 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
!su.active && "opacity-50",
|
||||
)}
|
||||
style={{ gridTemplateColumns: "1fr 130px 120px 90px 36px 32px" }}>
|
||||
|
||||
{/* login + description */}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-mono font-medium truncate">{su.login}</p>
|
||||
{su.description && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{su.description}</p>
|
||||
)}
|
||||
{su.lastSeen && (
|
||||
<p className="text-[10px] text-muted-foreground/50">{su.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* password */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="font-mono text-[11px] truncate flex-1">
|
||||
{revealed ? su.password : "••••••••••••"}
|
||||
</span>
|
||||
<button onClick={() => toggleReveal(su.id)}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||||
{revealed
|
||||
? <EyeOffIcon className="size-3" />
|
||||
: <EyeIcon className="size-3" />}
|
||||
</button>
|
||||
<button onClick={() => navigator.clipboard.writeText(su.password).catch(() => {})}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0 transition-colors">
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* JH servers */}
|
||||
<div className="flex flex-wrap gap-1 min-w-0">
|
||||
{jhs.length === 0
|
||||
? <span className="text-[11px] text-muted-foreground/40">—</span>
|
||||
: jhs.map(jh => (
|
||||
<span key={jh.id} className="inline-flex items-center gap-1 text-[10px] font-medium
|
||||
bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20
|
||||
rounded px-1 py-0.5">
|
||||
<Flag code={jh.country} size={10} />
|
||||
{jh.name.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* client IP */}
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">
|
||||
{su.clientIp || "—"}
|
||||
</span>
|
||||
|
||||
{/* active toggle */}
|
||||
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||
|
||||
{/* delete */}
|
||||
<button onClick={() => removeSubUser(su.id)}
|
||||
className="text-muted-foreground/40 hover:text-destructive transition-colors flex justify-end">
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SubusersDataGrid
|
||||
subUsers={form.subUsers}
|
||||
servers={servers}
|
||||
revealedIds={revealedIds}
|
||||
onToggleReveal={toggleReveal}
|
||||
onToggleActive={toggleSubUser}
|
||||
onRemove={removeSubUser}
|
||||
/>
|
||||
|
||||
{/* inline add form */}
|
||||
{addSubOpen ? (
|
||||
@@ -775,22 +683,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 ───────────────────────────────────────────────────────────
|
||||
|
||||
function DatabaseRestoreConfirm({
|
||||
@@ -900,7 +792,7 @@ export default function SettingsPage() {
|
||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||
const [dbRestoreFile, setDbRestoreFile] = useState<File | null>(null)
|
||||
const dbRestoreInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dbRestoreDialogOpen, setDbRestoreDialogOpen] = useState(false)
|
||||
|
||||
// notifications
|
||||
const [notifEmail, setNotifEmail] = useState(true)
|
||||
@@ -1031,7 +923,6 @@ export default function SettingsPage() {
|
||||
await restoreSystemDatabaseBackup(backendUrl, dbRestoreFile)
|
||||
toast.success("База приложения восстановлена")
|
||||
setDbRestoreFile(null)
|
||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось восстановить базу")
|
||||
} finally {
|
||||
@@ -1230,30 +1121,16 @@ export default function SettingsPage() {
|
||||
description="Полностью заменяет текущую базу SQLite"
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Input
|
||||
ref={dbRestoreInputRef}
|
||||
type="file"
|
||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||
className="hidden"
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!systemDbAvailable || dbBackupBusy || dbRestoreBusy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] ?? null
|
||||
if (!file) return
|
||||
setDbRestoreFile(file)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
onClick={() => setDbRestoreDialogOpen(true)}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
Выбрать файл
|
||||
</Button>
|
||||
{dbRestoreFile && (
|
||||
<p className="text-xs text-muted-foreground max-w-[220px] text-right break-all">{dbRestoreFile.name}</p>
|
||||
)}
|
||||
@@ -1384,7 +1261,7 @@ export default function SettingsPage() {
|
||||
label="Подставлять данные EvoBGP"
|
||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||
>
|
||||
<Toggle
|
||||
<FormToggle
|
||||
checked={evoEnabledDraft}
|
||||
onChange={(v) => setEvoEnabledDraft(v)}
|
||||
/>
|
||||
@@ -1496,15 +1373,15 @@ export default function SettingsPage() {
|
||||
<CardHeader><CardTitle className="text-base">Каналы уведомлений</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
||||
<Toggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
</SettingRow>
|
||||
{notifEmail && <div className="py-3"><Input className="text-sm h-8" defaultValue="admin@routerlists.io" /></div>}
|
||||
<SettingRow label="Slack" description="Webhook-интеграция с каналом #alerts">
|
||||
<Toggle checked={notifSlack} onChange={setNotifSlack} />
|
||||
<FormToggle checked={notifSlack} onChange={setNotifSlack} />
|
||||
</SettingRow>
|
||||
{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">
|
||||
<Toggle checked={notifWh} onChange={setNotifWh} />
|
||||
<FormToggle checked={notifWh} onChange={setNotifWh} />
|
||||
</SettingRow>
|
||||
{notifWh && <div className="py-3"><Input className="text-sm h-8 font-mono" defaultValue="https://hooks.example.com/routerlists" /></div>}
|
||||
</CardContent>
|
||||
@@ -1513,16 +1390,16 @@ export default function SettingsPage() {
|
||||
<CardHeader><CardTitle className="text-base">Триггеры</CardTitle></CardHeader>
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Деградация узла" description="Потери пакетов > 5% или RTT > 100мс">
|
||||
<Toggle checked={notifDegr} onChange={setNotifDegr} />
|
||||
<FormToggle checked={notifDegr} onChange={setNotifDegr} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Узел ушёл offline">
|
||||
<Toggle checked={notifOffline} onChange={setNotifOffline} />
|
||||
<FormToggle checked={notifOffline} onChange={setNotifOffline} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Падение BGP-сессии">
|
||||
<Toggle checked={notifBgp} onChange={setNotifBgp} />
|
||||
<FormToggle checked={notifBgp} onChange={setNotifBgp} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Просроченный бэкап" description="Если последний бэкап старше 2 дней">
|
||||
<Toggle checked={notifBackup} onChange={setNotifBackup} />
|
||||
<FormToggle checked={notifBackup} onChange={setNotifBackup} />
|
||||
</SettingRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1601,66 +1478,17 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* access summary */}
|
||||
<Card className="overflow-hidden gap-0 py-0">
|
||||
<DataPageCard>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<UserIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium">Сводка прав доступа</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-[11px] text-muted-foreground bg-muted/30">
|
||||
<th className="text-left font-medium px-4 py-2">Пользователь</th>
|
||||
<th className="text-left font-medium px-4 py-2">Разделы</th>
|
||||
<th className="text-left font-medium px-4 py-2">Серверы</th>
|
||||
<th className="text-left font-medium px-4 py-2">Права записи</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map(u => {
|
||||
const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section)
|
||||
const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section)
|
||||
const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none"))
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<span className="text-sm font-medium">{u.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{u.role === "admin"
|
||||
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({ALL_SECTIONS.length})</span>
|
||||
: <span>{(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{u.role === "admin"
|
||||
? <span className="text-violet-600 dark:text-violet-400 font-medium">Все ({servers.length})</span>
|
||||
: <span>{accessServers.length} из {servers.length}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.role === "admin"
|
||||
? <span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20">Полный доступ</span>
|
||||
: writeSections.length === 0
|
||||
? <span className="text-[10px] text-muted-foreground">Только просмотр</span>
|
||||
: writeSections.slice(0, 3).map(s => (
|
||||
<span key={s} className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">{s}</span>
|
||||
))
|
||||
}
|
||||
{u.role !== "admin" && writeSections.length > 3 && (
|
||||
<span className="text-[10px] text-muted-foreground">+{writeSections.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<SettingsAccessSummaryDataGrid
|
||||
users={users}
|
||||
servers={servers}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
/>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1741,7 +1569,7 @@ export default function SettingsPage() {
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||
description="TOTP / Authenticator app для всех администраторов">
|
||||
<Toggle checked={mfa} onChange={setMfa} />
|
||||
<FormToggle checked={mfa} onChange={setMfa} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Тайм-аут сессии (мин)" description="Автоматический выход при бездействии">
|
||||
<Input className="w-20 h-8 text-sm" value={sessMin} onChange={e => setSessMin(e.target.value)} />
|
||||
@@ -1763,7 +1591,7 @@ export default function SettingsPage() {
|
||||
<CardContent className="divide-y px-5">
|
||||
<SettingRow label="Расширенный журнал аудита"
|
||||
description="Записывать все изменения конфигурации с указанием пользователя и IP">
|
||||
<Toggle checked={auditLog} onChange={setAuditLog} />
|
||||
<FormToggle checked={auditLog} onChange={setAuditLog} />
|
||||
</SettingRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1853,7 +1681,6 @@ export default function SettingsPage() {
|
||||
onCancel={() => {
|
||||
if (dbRestoreBusy) return
|
||||
setDbRestoreFile(null)
|
||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1864,6 +1691,19 @@ export default function SettingsPage() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -427,8 +427,8 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
|
||||
@@ -758,8 +758,8 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
|
||||
]
|
||||
|
||||
export default function TrafficPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
||||
|
||||
+79
-463
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -9,6 +10,13 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import {
|
||||
UptimeResourcesDataGrid,
|
||||
type UptimeResourceRow,
|
||||
} from "@/components/data-grids/uptime-resources-data-grid"
|
||||
import { UptimeSpeedHistoryDataGrid } from "@/components/data-grids/uptime-speed-history-data-grid"
|
||||
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
||||
@@ -212,21 +220,6 @@ function probeGroupActionKey(srvId: string, group: { name: string; target: strin
|
||||
|
||||
// ── 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" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
@@ -267,18 +260,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 */
|
||||
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
||||
return i.running === true && i.disabled !== true
|
||||
@@ -323,36 +304,6 @@ function interfaceOptionMatchesSearch(iface: RouterInterfaceOption, raw: string)
|
||||
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({
|
||||
options,
|
||||
selectedId,
|
||||
@@ -910,74 +861,55 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
|
||||
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
|
||||
|
||||
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
|
||||
const [sortKey, setSortKey] = useState<ResSortKey>("name")
|
||||
const [sortAsc, setSortAsc] = useState(true)
|
||||
const [resSearch, setResSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
||||
|
||||
const rows = useMemo(() => resources.map((r) => {
|
||||
const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => {
|
||||
const hasData = r.hasData !== false
|
||||
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
|
||||
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
|
||||
return {
|
||||
...r,
|
||||
hasData,
|
||||
server: serversList.find(s => s.id === r.serverId),
|
||||
server: serversList.find(s => s.id === r.serverId)!,
|
||||
ramPct,
|
||||
hddPct,
|
||||
}
|
||||
}).filter(r => r.server !== undefined), [resources, serversList])
|
||||
}).filter(r => serversList.some(s => s.id === r.serverId)), [resources, serversList])
|
||||
|
||||
// KPI aggregates (только серверы с реальными сэмплами за окно)
|
||||
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
|
||||
const onlineWithSamples = rows.filter(r => r.server.status === "online" && r.hasData)
|
||||
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
|
||||
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
|
||||
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
|
||||
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
|
||||
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||
const highCpu = rows.filter(r => r.server.status === "online" && r.hasData && r.cpu >= 85).length
|
||||
const highRam = rows.filter(r => r.server.status === "online" && r.hasData && r.ramPct >= 85).length
|
||||
const highHdd = rows.filter(r => r.server.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||
|
||||
// Alerts
|
||||
const alerts = useMemo(() =>
|
||||
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
|
||||
rows.filter(r => r.server.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
|
||||
[rows],
|
||||
)
|
||||
|
||||
// Filtered + sorted
|
||||
const visible = useMemo(() => {
|
||||
let list = rows
|
||||
if (typeFilter !== "all") list = list.filter(r => r.server!.type === typeFilter)
|
||||
if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter)
|
||||
if (resSearch.trim()) {
|
||||
const q = resSearch.toLowerCase()
|
||||
list = list.filter(r =>
|
||||
r.server!.name.toLowerCase().includes(q) ||
|
||||
r.server!.site.toLowerCase().includes(q) ||
|
||||
r.server.name.toLowerCase().includes(q) ||
|
||||
r.server.site.toLowerCase().includes(q) ||
|
||||
r.boardName.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
list = [...list].sort((a, b) => {
|
||||
let diff = 0
|
||||
switch (sortKey) {
|
||||
case "name": diff = a.server!.name.localeCompare(b.server!.name); break
|
||||
case "cpu": diff = a.cpu - b.cpu; break
|
||||
case "ram": diff = a.ramPct - b.ramPct; break
|
||||
case "hdd": diff = a.hddPct - b.hddPct; break
|
||||
case "uptime": diff = a.uptimeSeconds - b.uptimeSeconds; break
|
||||
case "temp": diff = (a.temp ?? -1) - (b.temp ?? -1); break
|
||||
}
|
||||
return sortAsc ? diff : -diff
|
||||
})
|
||||
return list
|
||||
}, [rows, typeFilter, resSearch, sortKey, sortAsc])
|
||||
|
||||
function toggleSort(k: ResSortKey) {
|
||||
if (sortKey === k) setSortAsc(v => !v)
|
||||
else { setSortKey(k); setSortAsc(false) } // default desc for metrics
|
||||
}
|
||||
}, [rows, typeFilter, resSearch])
|
||||
|
||||
function exportCsv() {
|
||||
const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"]
|
||||
const rowsCsv = visible.map(r => {
|
||||
const s = r.server!
|
||||
const s = r.server
|
||||
return [s.name, s.type, s.site, r.cpu, r.ramPct, fmtMB(r.ramUsed), fmtMB(r.ramTotal),
|
||||
r.hddPct, fmtMB(r.hddUsed), fmtMB(r.hddTotal), fmtUptime(r.uptimeSeconds),
|
||||
r.temp ?? "", s.os].join(",")
|
||||
@@ -1010,7 +942,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alerts.map(r => {
|
||||
const s = r.server!
|
||||
const s = r.server
|
||||
const issues: string[] = []
|
||||
if (r.cpu >= 85) issues.push(`CPU ${r.cpu}%`)
|
||||
if (r.ramPct >= 85) issues.push(`RAM ${r.ramPct}%`)
|
||||
@@ -1050,236 +982,27 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Toolbar ───────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Type filter */}
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
|
||||
{typeOpts.map(o => (
|
||||
<button key={o.value} onClick={() => setTypeFilter(o.value)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
|
||||
typeFilter === o.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative min-w-[180px] flex-1 max-w-xs">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск по имени, площадке…"
|
||||
value={resSearch} onChange={e => setResSearch(e.target.value)} />
|
||||
{resSearch && (
|
||||
<button onClick={() => setResSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{visible.length} из {rows.length} серверов
|
||||
</span>
|
||||
|
||||
{/* Export CSV */}
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
|
||||
<DownloadIcon className="size-3.5" />CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/30 text-xs text-muted-foreground font-medium">
|
||||
|
||||
{/* Sortable: name */}
|
||||
<th className="text-left px-5 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("name")}>
|
||||
<span className="flex items-center gap-0.5">
|
||||
Сервер <SortIcon k="name" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell whitespace-nowrap">Модель · ROS</th>
|
||||
|
||||
{/* Sortable: cpu */}
|
||||
<th className="text-left px-4 py-3 min-w-[160px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("cpu")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5" />CPU
|
||||
<SortIcon k="cpu" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: ram */}
|
||||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("ram")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />RAM
|
||||
<SortIcon k="ram" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: hdd */}
|
||||
<th className="text-left px-4 py-3 min-w-[175px] cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("hdd")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5" />Диск
|
||||
<SortIcon k="hdd" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: uptime */}
|
||||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("uptime")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5" />Uptime
|
||||
<SortIcon k="uptime" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
|
||||
{/* Sortable: temp */}
|
||||
<th className="text-left px-4 py-3 cursor-pointer hover:text-foreground transition-colors select-none"
|
||||
onClick={() => toggleSort("temp")}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5" />°C
|
||||
<SortIcon k="temp" sortKey={sortKey} sortAsc={sortAsc} />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{visible.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-sm text-muted-foreground py-12">
|
||||
<SearchIcon className="size-6 mx-auto mb-2 opacity-20" />
|
||||
Ничего не найдено
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{visible.map(r => {
|
||||
const srv = r.server!
|
||||
const offline = srv.status !== "online"
|
||||
const hasSamples = r.hasData !== false
|
||||
const noMetrics = offline || !hasSamples
|
||||
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={r.serverId} className={cn(
|
||||
"hover:bg-muted/30 transition-colors",
|
||||
offline && "opacity-50",
|
||||
isCrit && "bg-red-500/3",
|
||||
)}>
|
||||
|
||||
{/* Server */}
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && r.hasData === false && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Board + ROS */}
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* CPU */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* RAM */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* HDD */}
|
||||
<td className="px-4 py-3">
|
||||
{noMetrics
|
||||
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
: (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Uptime */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Temp */}
|
||||
<td className="px-4 py-3">
|
||||
{r.temp !== undefined && !noMetrics ? (
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70 ? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: typeFilter,
|
||||
onChange: setTypeFilter,
|
||||
options: typeOpts,
|
||||
}}
|
||||
search={resSearch}
|
||||
onSearchChange={setResSearch}
|
||||
searchPlaceholder="Поиск по имени, площадке…"
|
||||
countLabel={`${visible.length} из ${rows.length} серверов`}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 shrink-0" onClick={exportCsv}>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
CSV
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<UptimeResourcesDataGrid rows={visible} />
|
||||
</DataPageCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground/40 text-center">
|
||||
{liveApi
|
||||
@@ -1293,15 +1016,15 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
// ── page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UptimePage() {
|
||||
const [allServers, setAllServers] = useState<Server[]>(mockServers)
|
||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||||
const liveApi = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [allServers, setAllServers] = useState<Server[]>([])
|
||||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
||||
const [probes, setProbes] = useState<PingProbe[]>(INIT_PROBES)
|
||||
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
|
||||
const [probes, setProbes] = useState<PingProbe[]>([])
|
||||
const [resources, setResources] = useState<ServerResource[]>([])
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
||||
@@ -1334,18 +1057,12 @@ export default function UptimePage() {
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveApi) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAllServers(mockServers)
|
||||
return
|
||||
}
|
||||
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
|
||||
}, [liveApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveApi) return
|
||||
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAllServers(mockServers)
|
||||
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
||||
setResources(INIT_RESOURCES)
|
||||
}, [liveApi])
|
||||
|
||||
const isPausedRef = useRef(isPaused)
|
||||
@@ -2407,7 +2124,7 @@ export default function UptimePage() {
|
||||
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">
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
@@ -2534,114 +2251,13 @@ export default function UptimePage() {
|
||||
|
||||
{/* ── history ── */}
|
||||
{speedRuns.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<DataPageCard>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<p className="text-sm font-medium">История тестов</p>
|
||||
<span className="text-xs text-muted-foreground">{speedRuns.length} запусков</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-muted-foreground">
|
||||
{["Время", "Маршрут", "Параметры", "Статус", "TX avg", "RX avg", "Ping после BT"].map(h => (
|
||||
<th key={h} className="px-4 py-2.5 text-left font-medium whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{speedRuns.map((run) => {
|
||||
const src = allServers.find((s) => s.id === run.srcServerId)
|
||||
const dst = allServers.find((s) => s.id === run.dstServerId)
|
||||
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
|
||||
return (
|
||||
<tr key={run.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums font-mono">
|
||||
{new Date(run.startedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={src?.country ?? "UN"} size={13} />
|
||||
<span>{src?.name ?? run.srcServerId}</span>
|
||||
<ArrowRightIcon className="size-3 text-muted-foreground" />
|
||||
<Flag code={dst?.country ?? "UN"} size={13} />
|
||||
<span>{dst?.name ?? run.dstServerId}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{run.srcInterfaceAddress && run.dstInterfaceAddress
|
||||
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
|
||||
: "внутренние IP: auto/не указаны"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
|
||||
{run.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.direction}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>{run.durationSec}s</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{run.status === "running" ? (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)]">
|
||||
<RefreshCwIcon className="size-3 animate-spin" />running
|
||||
</span>
|
||||
) : run.status === "error" ? (
|
||||
<span className="text-[var(--status-offline-fg)]">error</span>
|
||||
) : (
|
||||
<span className="text-[var(--status-online-fg)]">done</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--chart-tx)]"
|
||||
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap">
|
||||
{run.txAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--chart-rx)]"
|
||||
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }} />
|
||||
</div>
|
||||
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap">
|
||||
{run.rxAvgMbps} Мбит/с
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums whitespace-nowrap">
|
||||
{run.status !== "done" ? "—" : run.afterBtPing?.error ? (
|
||||
<span className="text-[var(--status-offline-fg)]" title={run.afterBtPing.error}>
|
||||
ошибка
|
||||
</span>
|
||||
) : run.afterBtPing?.rttMs != null ? (
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
{run.afterBtPing.rttMs} мс
|
||||
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
timeout
|
||||
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<UptimeSpeedHistoryDataGrid runs={speedRuns} servers={allServers} />
|
||||
</DataPageCard>
|
||||
)}
|
||||
|
||||
</div>
|
||||
@@ -2830,7 +2446,7 @@ export default function UptimePage() {
|
||||
!p.enabled && "opacity-40",
|
||||
)}
|
||||
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
|
||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
pulse={p.status === "up" && p.enabled}
|
||||
@@ -3008,7 +2624,7 @@ export default function UptimePage() {
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
<Field label="Источник">
|
||||
<FormField label="Источник">
|
||||
<ServerPickerCards
|
||||
options={selectableSources}
|
||||
selectedId={speedDraft.srcServerId}
|
||||
@@ -3021,9 +2637,9 @@ export default function UptimePage() {
|
||||
void loadSpeedInterfaces(nextSrc)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Назначение">
|
||||
<FormField label="Назначение">
|
||||
<ServerPickerCards
|
||||
options={selectableSources.filter((s) => s.id !== speedDraft.srcServerId)}
|
||||
selectedId={speedDraft.dstServerId}
|
||||
@@ -3032,28 +2648,28 @@ export default function UptimePage() {
|
||||
void loadSpeedInterfaces(nextDst)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Интерфейс источника">
|
||||
<FormField label="Интерфейс источника">
|
||||
<InterfacePickerCards
|
||||
value={speedDraft.srcInterface}
|
||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, srcInterface: v }))}
|
||||
options={filterActiveInterfaces(speedIfaces[speedDraft.srcServerId] ?? [])}
|
||||
autoLabel="auto"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Интерфейс назначения">
|
||||
<FormField label="Интерфейс назначения">
|
||||
<InterfacePickerCards
|
||||
value={speedDraft.dstInterface}
|
||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, dstInterface: v }))}
|
||||
options={filterActiveInterfaces(speedIfaces[speedDraft.dstServerId] ?? [])}
|
||||
autoLabel="auto"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Протокол">
|
||||
<FormField label="Протокол">
|
||||
<SegmentedControl
|
||||
value={speedDraft.protocol}
|
||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, protocol: v }))}
|
||||
@@ -3062,8 +2678,8 @@ export default function UptimePage() {
|
||||
{ value: "udp", label: "UDP" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Direction">
|
||||
</FormField>
|
||||
<FormField label="Direction">
|
||||
<SegmentedControl
|
||||
value={speedDraft.direction}
|
||||
onChange={(v) => setSpeedDraft((prev) => ({ ...prev, direction: v }))}
|
||||
@@ -3073,10 +2689,10 @@ export default function UptimePage() {
|
||||
{ value: "receive", label: "rx" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Сек">
|
||||
</FormField>
|
||||
<FormField label="Сек">
|
||||
<Input className="h-9" value={speedDraft.durationSec} onChange={(e) => setSpeedDraft((prev) => ({ ...prev, durationSec: e.target.value }))} />
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{speedDraft.srcServerId &&
|
||||
@@ -3175,7 +2791,7 @@ export default function UptimePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<FormField
|
||||
label="Источник (кто пингует)"
|
||||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||
>
|
||||
@@ -3184,9 +2800,9 @@ export default function UptimePage() {
|
||||
selectedId={newSrcId}
|
||||
onSelect={(id) => setNewSrcId(id)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Интерфейс источника" hint="(необязательно)">
|
||||
<FormField label="Интерфейс источника" hint="(необязательно)">
|
||||
<InterfacePickerCards
|
||||
value={newSrcInterface}
|
||||
onChange={setNewSrcInterface}
|
||||
@@ -3194,25 +2810,25 @@ export default function UptimePage() {
|
||||
autoLabel="авто (по маршруту)"
|
||||
busy={srcInterfacesBusy}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Имя пробы">
|
||||
<FormField label="Имя пробы">
|
||||
<Input className="h-9 text-sm" placeholder="youtube.com"
|
||||
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"
|
||||
value={newTarget} onChange={e => setNewTarget(e.target.value)} />
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field label="Связанный фильтр" hint="(необязательно)">
|
||||
<FormField label="Связанный фильтр" hint="(необязательно)">
|
||||
<LinkedFilterPickerCards
|
||||
value={newFilter}
|
||||
onChange={setNewFilter}
|
||||
items={filters}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
|
||||
+18
-140
@@ -4,17 +4,14 @@ import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
|
||||
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
|
||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
@@ -125,100 +122,7 @@ function ExportSheet({ open, tunnel, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tunnel row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TunnelRow({
|
||||
tunnel,
|
||||
onExport,
|
||||
}: {
|
||||
tunnel: VxlanTunnel
|
||||
onExport: () => void
|
||||
}) {
|
||||
const srv = serverFor(tunnel.serverId)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||
!tunnel.enabled && "opacity-50",
|
||||
)}>
|
||||
{/* status dot */}
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||
)} />
|
||||
|
||||
{/* name */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
|
||||
</div>
|
||||
|
||||
{/* VNI */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">VNI</p>
|
||||
<p className="font-mono text-sm">{tunnel.vni}</p>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Port</p>
|
||||
<p className="font-mono text-sm">{tunnel.dstPort}</p>
|
||||
</div>
|
||||
|
||||
{/* Remote VTEPs */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
|
||||
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
|
||||
</div>
|
||||
|
||||
{/* ARP Proxy */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
|
||||
ARP {tunnel.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* MAC learning */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
|
||||
MAC {tunnel.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
tunnel.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{tunnel.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||
@@ -284,45 +188,19 @@ export default function VxlanPage() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, VNI, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Имя / VTEP IP</span>
|
||||
<span>Сервер</span>
|
||||
<span>VNI</span>
|
||||
<span>Port</span>
|
||||
<span>Remote</span>
|
||||
<span />
|
||||
<span />
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<NetworkIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||
countLabel={`${filtered.length} туннелей`}
|
||||
/>
|
||||
<VxlanDataGrid
|
||||
tunnels={filtered}
|
||||
servers={servers}
|
||||
onExport={setExportTunnel}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
{/* Reference */}
|
||||
<Card>
|
||||
|
||||
+24
-238
@@ -3,34 +3,28 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { servers } from "@/lib/data"
|
||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import {
|
||||
WireguardDataGrid,
|
||||
type WgIfaceWithServer,
|
||||
} from "@/components/data-grids/wireguard-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
|
||||
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
|
||||
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
CodeXmlIcon, UsersIcon, ActivityIcon,
|
||||
CopyIcon, CheckIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||||
|
||||
interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
function collectInterfaces(): WgIfaceWithServer[] {
|
||||
const result: WgIfaceWithServer[] = []
|
||||
for (const srv of servers) {
|
||||
@@ -48,19 +42,6 @@ function collectInterfaces(): WgIfaceWithServer[] {
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (!n) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||
@@ -89,161 +70,6 @@ function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Peer row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PeerRow({ peer }: { peer: WireGuardPeer }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
|
||||
{/* public key */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
{/* allowed IPs */}
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
{/* handshake */}
|
||||
<span className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
{/* rx / tx */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
{/* endpoint */}
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Interface card ───────────────────────────────────────────────────────────
|
||||
|
||||
function IfaceRow({
|
||||
iface,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onExport,
|
||||
}: {
|
||||
iface: WgIfaceWithServer
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onExport: () => void
|
||||
}) {
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
|
||||
return (
|
||||
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
|
||||
<div
|
||||
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{/* expand */}
|
||||
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5" />
|
||||
: <ChevronRightIcon className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* name + server */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
)} />
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Порт</p>
|
||||
<p className="font-mono text-sm">{iface.listenPort}</p>
|
||||
</div>
|
||||
|
||||
{/* MTU */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">MTU</p>
|
||||
<p className="font-mono text-sm">{iface.mtu}</p>
|
||||
</div>
|
||||
|
||||
{/* peers */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Пиров</p>
|
||||
<p className="font-mono text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
iface.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{iface.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
|
||||
<CodeXmlIcon className="size-4" />Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* expanded peers */}
|
||||
{expanded && iface.peers.length > 0 && (
|
||||
<div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
|
||||
<span>Public Key</span>
|
||||
<span>Allowed IPs</span>
|
||||
<span>Последнее рукопожатие</span>
|
||||
<span>RX / TX</span>
|
||||
<span>Endpoint</span>
|
||||
</div>
|
||||
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && iface.peers.length === 0 && (
|
||||
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, iface, onClose }: {
|
||||
@@ -309,8 +135,7 @@ function ExportSheet({ open, iface, onClose }: {
|
||||
export default function WireGuardPage() {
|
||||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -327,14 +152,6 @@ export default function WireGuardPage() {
|
||||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
@@ -384,49 +201,18 @@ export default function WireGuardPage() {
|
||||
</div>
|
||||
|
||||
{/* Search + table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, серверу, IP…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Интерфейс / Сервер</span>
|
||||
<span>Порт</span>
|
||||
<span>MTU</span>
|
||||
<span>Пиры</span>
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
|
||||
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((iface) => (
|
||||
<IfaceRow
|
||||
key={iface.id}
|
||||
iface={iface}
|
||||
expanded={expandedIds.has(iface.id)}
|
||||
onToggleExpand={() => toggleExpand(iface.id)}
|
||||
onExport={() => setExportIface(iface)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||||
countLabel={`${filtered.length} интерфейсов`}
|
||||
/>
|
||||
<WireguardDataGrid
|
||||
interfaces={filtered}
|
||||
onExport={setExportIface}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
|
||||
@@ -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-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-info: var(--info);
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-invert: var(--invert);
|
||||
--color-invert-foreground: var(--invert-foreground);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -74,6 +83,15 @@
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--info: var(--color-violet-500);
|
||||
--info-foreground: var(--color-violet-900);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
--warning: var(--color-yellow-500);
|
||||
--warning-foreground: var(--color-yellow-900);
|
||||
--invert: var(--color-zinc-900);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
|
||||
/* Borders + inputs */
|
||||
--border: oklch(0.904 0.006 264.0);
|
||||
@@ -148,6 +166,15 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--info: var(--color-violet-500);
|
||||
--info-foreground: var(--color-violet-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
--warning: var(--color-yellow-500);
|
||||
--warning-foreground: var(--color-yellow-600);
|
||||
--invert: var(--color-zinc-700);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RouterLists",
|
||||
title: "MikrotikManager",
|
||||
description: "MikroTik network management platform",
|
||||
};
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ dist/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
storage/backups/
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Database from "better-sqlite3"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
type SqliteHandle = InstanceType<typeof Database>
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||
@@ -338,6 +340,72 @@ CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
server_id TEXT NOT NULL,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc',
|
||||
server_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_run_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -587,12 +655,70 @@ SELECT 1, '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
|
||||
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
|
||||
SELECT 1, 1, 21600, 30
|
||||
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
|
||||
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
||||
SELECT 1, NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
||||
`)
|
||||
|
||||
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
|
||||
if (backupEntryCount.c === 0) {
|
||||
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
|
||||
if (existsSync(legacyIndexPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
const insert = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
|
||||
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
|
||||
`)
|
||||
for (const row of parsed) {
|
||||
if (!row || typeof row !== "object") continue
|
||||
const item = row as Record<string, unknown>
|
||||
const id = String(item.id ?? "").trim()
|
||||
const filename = String(item.filename ?? "").trim()
|
||||
if (!id || !filename) continue
|
||||
insert.run({
|
||||
id,
|
||||
serverId: String(item.serverId ?? ""),
|
||||
serverName: String(item.serverName ?? ""),
|
||||
filename,
|
||||
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
|
||||
kind: item.kind === "auto" ? "auto" : "manual",
|
||||
notes: item.notes == null ? null : String(item.notes),
|
||||
createdAt: String(item.createdAt ?? new Date().toISOString()),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* legacy index.json не читается — пропускаем */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
|
||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||
|
||||
@@ -287,6 +287,70 @@ export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const acmeSettings = sqliteTable("acme_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
|
||||
defaultZoneId: text("default_zone_id").notNull().default(""),
|
||||
accountPrivateKey: text("account_private_key").notNull().default(""),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
|
||||
step: text("step").notNull().default("queued"),
|
||||
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
|
||||
serverId: text("server_id").notNull(),
|
||||
certName: text("cert_name").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
keyType: text("key_type").notNull().default("rsa2048"),
|
||||
trustStore: text("trust_store").notNull().default("www,api"),
|
||||
requestedAt: text("requested_at").notNull().default(sql`(datetime('now'))`),
|
||||
startedAt: text("started_at"),
|
||||
finishedAt: text("finished_at"),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(21600),
|
||||
renewBeforeDays: integer("renew_before_days").notNull().default(30),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
|
||||
hour: integer("hour").notNull().default(3),
|
||||
minute: integer("minute").notNull().default(0),
|
||||
weekDay: integer("week_day").notNull().default(0),
|
||||
monthDay: integer("month_day").notNull().default(1),
|
||||
keepCount: integer("keep_count").notNull().default(7),
|
||||
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
|
||||
serverIdsJson: text("server_ids_json").notNull().default("[]"),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const backupEntries = sqliteTable("backup_entries", {
|
||||
id: text("id").primaryKey(),
|
||||
serverId: text("server_id").notNull(),
|
||||
serverName: text("server_name").notNull(),
|
||||
filename: text("filename").notNull(),
|
||||
sizeBytes: integer("size_bytes").notNull(),
|
||||
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
})
|
||||
|
||||
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||
export const alertGroups = sqliteTable("alert_groups", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -505,6 +569,11 @@ export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||
export type EventRow = typeof events.$inferSelect
|
||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
|
||||
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
|
||||
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
|
||||
export type CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
|
||||
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$inferSelect
|
||||
export type BackupEntryRow = typeof backupEntries.$inferSelect
|
||||
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||
|
||||
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
|
||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||
import alertsRoutes from "./routes/alerts.js"
|
||||
import backupsRoutes from "./routes/backups.js"
|
||||
import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
@@ -26,6 +27,8 @@ import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
@@ -70,6 +73,7 @@ await app.register(schedulerRoutes, { prefix: "/api" })
|
||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||
await app.register(alertsRoutes, { prefix: "/api" })
|
||||
await app.register(backupsRoutes, { prefix: "/api" })
|
||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||
await app.register(eventsRoutes, { prefix: "/api" })
|
||||
|
||||
|
||||
@@ -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 { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
|
||||
type BackupMeta = {
|
||||
id: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
kind: "manual"
|
||||
notes?: string
|
||||
}
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import {
|
||||
deleteBackupRecord,
|
||||
getBackupById,
|
||||
getBackupsDir,
|
||||
getBackupScheduleSettings,
|
||||
listBackups,
|
||||
runBackupForServer,
|
||||
updateBackupScheduleSettings,
|
||||
type BackupMeta,
|
||||
} from "../services/backup-service.js"
|
||||
|
||||
type BackupJobStatus = "queued" | "running" | "done" | "failed"
|
||||
type BackupJob = {
|
||||
@@ -32,36 +31,8 @@ type BackupJob = {
|
||||
failures: Array<{ serverId: string; error: string }>
|
||||
}
|
||||
|
||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
|
||||
const backupJobs = new Map<string, BackupJob>()
|
||||
|
||||
async function ensureStorage() {
|
||||
await mkdir(BACKUPS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
async function readIndex(): Promise<BackupMeta[]> {
|
||||
await ensureStorage()
|
||||
try {
|
||||
const raw = await readFile(INDEX_PATH, "utf8")
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed as BackupMeta[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(rows: BackupMeta[]): Promise<void> {
|
||||
await ensureStorage()
|
||||
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
|
||||
}
|
||||
|
||||
function fmtTs(d = new Date()): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const CreateBackupBodySchema = z.object({
|
||||
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
|
||||
notes: z.string().max(500).optional(),
|
||||
@@ -74,43 +45,12 @@ const BackupJobIdParamSchema = z.object({
|
||||
jobId: z.string().min(1),
|
||||
})
|
||||
|
||||
async function runBackupForServer(id: string, notes?: string): Promise<BackupMeta> {
|
||||
const serverIdNum = Number.parseInt(id, 10)
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
}
|
||||
const row = getServerRowById(serverIdNum)
|
||||
if (!row) {
|
||||
throw new Error("Сервер не найден")
|
||||
}
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const script = await client.exportConfigScript()
|
||||
const ts = fmtTs()
|
||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
const filename = `${safeServer}_${ts}.rsc`
|
||||
const filePath = path.join(BACKUPS_DIR, filename)
|
||||
await writeFile(filePath, script, "utf8")
|
||||
const st = await stat(filePath)
|
||||
return {
|
||||
id: randomUUID(),
|
||||
serverId: String(row.id),
|
||||
serverName: row.name,
|
||||
filename,
|
||||
sizeBytes: st.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: "manual",
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
job.status = "running"
|
||||
job.startedAt = new Date().toISOString()
|
||||
const indexRows = await readIndex()
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const meta = await runBackupForServer(id, notes)
|
||||
indexRows.unshift(meta)
|
||||
const meta = await runBackupForServer(id, "manual", notes)
|
||||
job.created.push(meta)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -119,7 +59,6 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
job.completed += 1
|
||||
}
|
||||
}
|
||||
await writeIndex(indexRows)
|
||||
job.status = "done"
|
||||
job.finishedAt = new Date().toISOString()
|
||||
appendEvent({
|
||||
@@ -141,9 +80,21 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
|
||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/backups", async (_req, reply) => {
|
||||
const rows = await readIndex()
|
||||
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
return reply.send(rows)
|
||||
return reply.send(listBackups())
|
||||
})
|
||||
|
||||
app.get("/backups/schedule", async (_req, reply) => {
|
||||
return reply.send(getBackupScheduleSettings())
|
||||
})
|
||||
|
||||
app.put("/backups/schedule", async (req, reply) => {
|
||||
const parsed = putBackupScheduleSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const result = updateBackupScheduleSettings(parsed.data)
|
||||
refreshScheduler()
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||
@@ -216,10 +167,9 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const rows = await readIndex()
|
||||
const hit = rows.find((r) => r.id === req.params.id)
|
||||
const hit = getBackupById(req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const filePath = path.join(BACKUPS_DIR, hit.filename)
|
||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||
@@ -228,12 +178,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const rows = await readIndex()
|
||||
const idx = rows.findIndex((r) => r.id === req.params.id)
|
||||
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const [hit] = rows.splice(idx, 1)
|
||||
await writeIndex(rows)
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
const hit = await deleteBackupRecord(req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 { parseDbServerId } from "../utils/server-id.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import {
|
||||
hasManagedCommentPrefix,
|
||||
managedComment,
|
||||
} from "../managed-markers.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
@@ -595,11 +599,11 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
const isInBgpIn = (r: RosFilterRule) =>
|
||||
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
||||
const managedComment = `RouterLists: ${server.name || server.host}`
|
||||
const managedCommentValue = managedComment(server.name || server.host)
|
||||
|
||||
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
||||
const managedRule = existing.find(
|
||||
r => isInBgpIn(r) && (r.comment ?? "").startsWith("RouterLists:"),
|
||||
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
|
||||
)
|
||||
|
||||
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
||||
@@ -608,7 +612,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const conflictIds = existing
|
||||
.filter(r =>
|
||||
isInBgpIn(r) &&
|
||||
!(r.comment ?? "").startsWith("RouterLists:") &&
|
||||
!hasManagedCommentPrefix(r.comment ?? "") &&
|
||||
/bgp-communities/i.test(r.rule ?? ""),
|
||||
)
|
||||
.map(r => r[".id"])
|
||||
@@ -641,7 +645,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
||||
{
|
||||
chain: "bgp-in",
|
||||
comment: managedComment,
|
||||
comment: managedCommentValue,
|
||||
rule: ruleBody,
|
||||
disabled: "no",
|
||||
},
|
||||
@@ -650,7 +654,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
} else {
|
||||
await client.post("/routing/filter/rule/add", {
|
||||
chain: "bgp-in",
|
||||
comment: managedComment,
|
||||
comment: managedCommentValue,
|
||||
rule: ruleBody,
|
||||
})
|
||||
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
||||
|
||||
@@ -4,6 +4,10 @@ import { db } from "../db/index.js"
|
||||
import { recursiveRoutes, servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { parseDbServerId } from "../utils/server-id.js"
|
||||
import {
|
||||
hasManagedRecursiveComment,
|
||||
managedRecursiveComment,
|
||||
} from "../managed-markers.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
@@ -108,9 +112,7 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
|
||||
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
|
||||
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
|
||||
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
|
||||
comment: route.comment
|
||||
? `RouterLists:recursive ${route.comment}`
|
||||
: "RouterLists:recursive",
|
||||
comment: managedRecursiveComment(route.comment),
|
||||
disabled: route.disabled ? "true" : "false",
|
||||
}
|
||||
}
|
||||
@@ -235,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const existing = await client.get<RosRoute[]>("/ip/route")
|
||||
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
|
||||
const managed = existing.filter(r => hasManagedRecursiveComment(r.comment ?? ""))
|
||||
for (const r of managed) {
|
||||
if (!r[".id"]) continue
|
||||
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -16,13 +17,15 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeProbesTotal,
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
] = [
|
||||
db.select().from(servers).all().length,
|
||||
db.select().from(filterRules).all().length,
|
||||
db.select().from(uptimeProbes).all().length,
|
||||
db.select().from(uptimeSpeedProbes).all().length,
|
||||
db.select().from(recursiveRoutes).all().length,
|
||||
]
|
||||
certificatesTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
servers: serversTotal,
|
||||
@@ -31,6 +34,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeSpeedProbes: uptimeSpeedProbesTotal,
|
||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||
recursiveRoutes: recursiveRoutesTotal,
|
||||
certificates: certificatesTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
|
||||
throw new Error("RouterOS REST: неожиданное тело ответа")
|
||||
}
|
||||
|
||||
function routerFileBasename(fileName: string): string {
|
||||
return fileName.replace(/^\/+/, "").split("/").pop() ?? fileName
|
||||
}
|
||||
|
||||
function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
const base = routerFileBasename(requested)
|
||||
return entryName === requested
|
||||
|| entryName === base
|
||||
|| entryName === `flash/${base}`
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
@@ -295,6 +363,10 @@ export class MikrotikClient {
|
||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
||||
}
|
||||
|
||||
async put<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
|
||||
return rosPut(this.params, path, body, timeoutMs) as Promise<T>
|
||||
}
|
||||
|
||||
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||
return rosDelete(this.params, path, timeoutMs)
|
||||
}
|
||||
@@ -431,6 +503,98 @@ export class MikrotikClient {
|
||||
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
|
||||
}
|
||||
|
||||
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {
|
||||
const raw = await this.get<unknown>("/certificate")
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
|
||||
}
|
||||
|
||||
async listFiles(): Promise<Array<{ name: string }>> {
|
||||
const raw = await this.get<unknown>("/file")
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
|
||||
.map((row) => ({ name: String(row.name ?? "") }))
|
||||
.filter((row) => row.name.length > 0)
|
||||
}
|
||||
|
||||
private async resolveUploadedFileName(requested: string): Promise<string> {
|
||||
const files = await this.listFiles()
|
||||
const match = files.find((file) => matchesUploadedFile(file.name, requested))
|
||||
if (!match) {
|
||||
throw new Error(`Файл ${routerFileBasename(requested)} не найден на RouterOS после загрузки`)
|
||||
}
|
||||
return match.name
|
||||
}
|
||||
|
||||
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<string> {
|
||||
const normalized = routerFileBasename(fileName)
|
||||
const payload = contents.endsWith("\n") ? contents : `${contents}\n`
|
||||
const candidates = [`flash/${normalized}`, normalized]
|
||||
let lastError: unknown
|
||||
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
await this.put("/file", { name, contents: payload }, timeoutMs)
|
||||
return await this.resolveUploadedFileName(name)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
|
||||
try {
|
||||
await this.post("/file/add", { name, contents: payload, type: "file" }, timeoutMs)
|
||||
return await this.resolveUploadedFileName(name)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
|
||||
}
|
||||
|
||||
async importCertificate(params: {
|
||||
fileName: string
|
||||
name: string
|
||||
trusted?: boolean
|
||||
trustStore?: string
|
||||
passphrase?: string
|
||||
}): Promise<unknown> {
|
||||
const body: Record<string, string> = {
|
||||
"file-name": params.fileName,
|
||||
name: params.name,
|
||||
trusted: params.trusted === false ? "no" : "yes",
|
||||
"trust-store": params.trustStore?.trim() || "www,api",
|
||||
}
|
||||
if (params.passphrase?.trim()) body.passphrase = params.passphrase.trim()
|
||||
return this.post("/certificate/import", body, 60_000)
|
||||
}
|
||||
|
||||
async applyCertificateToServices(certName: string): Promise<void> {
|
||||
const body = {
|
||||
disabled: "no",
|
||||
certificate: certName,
|
||||
}
|
||||
for (const serviceName of ["www-ssl", "api-ssl"]) {
|
||||
await this.patchIpService(serviceName, body)
|
||||
}
|
||||
}
|
||||
|
||||
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
|
||||
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
|
||||
try {
|
||||
await this.patch(pathByName, body, 30_000)
|
||||
return
|
||||
} catch {
|
||||
const services = await this.get<Array<Record<string, string | undefined>>>("/ip/service")
|
||||
const row = services.find((service) => String(service.name ?? "") === serviceName)
|
||||
const id = row?.[".id"]
|
||||
if (!id) throw new Error(`Сервис RouterOS ${serviceName} не найден`)
|
||||
await this.patch(`/ip/service/${encodeURIComponent(id)}`, body, 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
async exportConfigScript(): Promise<string> {
|
||||
const raw = await this.post<unknown>("/console/export", {}, 30_000)
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
collectInternetPathSnapshotOnce,
|
||||
getInternetPathSettings,
|
||||
} from "./internet-path-collector.js"
|
||||
import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
|
||||
import { getCertificateRenewSettings } from "./certificates-service.js"
|
||||
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
|
||||
import { getBackupScheduleSettings } from "./backup-service.js"
|
||||
import {
|
||||
endSchedulerJob,
|
||||
isSchedulerJobRunning,
|
||||
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
|
||||
"uptime_speed",
|
||||
"internet_path",
|
||||
"gre_bgp",
|
||||
"certificates_renew",
|
||||
"backups",
|
||||
"alert_engine",
|
||||
] as const
|
||||
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
|
||||
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||
case "internet_path":
|
||||
snapshot = await collectInternetPathSnapshotOnce()
|
||||
break
|
||||
case "certificates_renew":
|
||||
snapshot = await collectCertificatesRenewOnce()
|
||||
break
|
||||
case "backups":
|
||||
snapshot = await collectScheduledBackupsOnce()
|
||||
break
|
||||
case "alert_engine": {
|
||||
const r = await runAlertEngineOnce()
|
||||
snapshot = {
|
||||
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
|
||||
}, greBgpMs),
|
||||
)
|
||||
|
||||
const certRenew = getCertificateRenewSettings()
|
||||
if (certRenew.enabled) {
|
||||
const certRenewMs = Math.max(300_000, certRenew.intervalSec * 1000)
|
||||
void executeSchedulerJob("certificates_renew").catch(() => {})
|
||||
timers.set(
|
||||
"certificates_renew",
|
||||
setInterval(() => {
|
||||
void executeSchedulerJob("certificates_renew").catch(() => {})
|
||||
}, certRenewMs),
|
||||
)
|
||||
}
|
||||
|
||||
const backupSchedule = getBackupScheduleSettings()
|
||||
if (backupSchedule.enabled) {
|
||||
const backupMs = 60_000
|
||||
void executeSchedulerJob("backups").catch(() => {})
|
||||
timers.set(
|
||||
"backups",
|
||||
setInterval(() => {
|
||||
void executeSchedulerJob("backups").catch(() => {})
|
||||
}, backupMs),
|
||||
)
|
||||
}
|
||||
|
||||
const alertMs = 20_000
|
||||
void executeSchedulerJob("alert_engine").catch(() => {})
|
||||
timers.set(
|
||||
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
|
||||
const uptime = getUptimeSettings()
|
||||
const apiPing = getServersApiPingSettings()
|
||||
const internetPath = getInternetPathSettings()
|
||||
const certRenew = getCertificateRenewSettings()
|
||||
const backupSchedule = getBackupScheduleSettings()
|
||||
|
||||
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
||||
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
||||
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
|
||||
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
||||
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
|
||||
gre_bgp: { enabled: true, intervalSec: 30 },
|
||||
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
|
||||
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
|
||||
alert_engine: { enabled: true, intervalSec: 20 },
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export interface CertificatesRenewTargetSnapshot {
|
||||
serverId: string
|
||||
certName: string
|
||||
action: "ok" | "renewed" | "skipped" | "error"
|
||||
daysLeft: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CertificatesRenewRunSnapshot {
|
||||
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
|
||||
job: "certificates_renew"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
checked: number
|
||||
renewed: number
|
||||
skippedTargets: number
|
||||
errors: string[]
|
||||
targets?: CertificatesRenewTargetSnapshot[]
|
||||
}
|
||||
|
||||
export interface BackupsRunSnapshot {
|
||||
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
|
||||
job: "backups"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
due: boolean
|
||||
created: number
|
||||
failures: number
|
||||
pruned: number
|
||||
errors?: string[]
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export type SchedulerRunSnapshot =
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
|
||||
| ServersRestPingRunSnapshot
|
||||
| GreBgpSnapshotRunSnapshot
|
||||
| InternetPathRunSnapshot
|
||||
| CertificatesRenewRunSnapshot
|
||||
| BackupsRunSnapshot
|
||||
| AlertEngineRunSnapshot
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
"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>) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||
@@ -142,7 +142,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
if (mode !== "live") {
|
||||
setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
@@ -176,15 +176,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendStatus, backendUrl])
|
||||
}, [mode, backendUrl])
|
||||
|
||||
const navGroups = React.useMemo((): NavGroup[] => {
|
||||
function badgeFor(url: string): string | undefined {
|
||||
if (mode !== "live") return mockBadges[url]
|
||||
if (backendStatus !== true) return undefined
|
||||
|
||||
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
|
||||
if (!evo.enabled) return mockBadges[url]
|
||||
if (!evo.enabled) return undefined
|
||||
if (!evo.snapshot) return undefined
|
||||
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
|
||||
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
|
||||
@@ -197,8 +196,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
|
||||
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
|
||||
|
||||
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
|
||||
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
badge: mounted ? badgeFor(it.url) : undefined,
|
||||
})),
|
||||
}))
|
||||
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
||||
@@ -227,7 +227,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<RouterIcon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
|
||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
|
||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Asn } from "@/lib/data"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { FilterIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
interface AsnsDataGridProps {
|
||||
asns: Asn[]
|
||||
isLoading?: boolean
|
||||
pagination?: boolean
|
||||
}
|
||||
|
||||
function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Asn>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "asn",
|
||||
accessorKey: "asn",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="ASN" className="ml-1" />,
|
||||
cell: ({ row }) => <span className="font-mono font-semibold">{row.original.asn}</span>,
|
||||
meta: {
|
||||
headerTitle: "ASN",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "org",
|
||||
accessorKey: "org",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / организация" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={row.original.org}>
|
||||
{row.original.org}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / организация",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixes",
|
||||
accessorKey: "prefixes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Префиксов" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums">{row.original.prefixes.toLocaleString("ru")}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Префиксов",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "filter",
|
||||
accessorKey: "filter",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Фильтр" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />
|
||||
{row.original.filter}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Фильтр",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
accessorKey: "updated",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Обновлён" />,
|
||||
cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.original.updated}</span>,
|
||||
meta: {
|
||||
headerTitle: "Обновлён",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={`text-xs font-medium ${row.original.enabled ? "text-emerald-600" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.original.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: asns,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={asns.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
pagination={pagination}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет ASN"
|
||||
description="Добавьте автономные системы или импортируйте каталог"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { AsnsDataGrid, type AsnsDataGridProps }
|
||||
@@ -0,0 +1,200 @@
|
||||
"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 { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
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 }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Файл",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "server",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
accessorKey: "size",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Размер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Размер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridSortHeader 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>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Заметки" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||
{row.original.notes || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Заметки",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "created",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Создан" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Создан",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<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,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[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 <DataGridShell table={table} recordCount={backups.length} />
|
||||
}
|
||||
|
||||
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,293 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
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 }) => (
|
||||
<DataGridSortHeader column={column} title="Роутер" className="ml-1" />
|
||||
),
|
||||
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: "Роутер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peerIp",
|
||||
accessorKey: "peerIp",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Peer IP" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||
meta: {
|
||||
headerTitle: "Peer IP",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remoteAs",
|
||||
accessorKey: "remoteAs",
|
||||
header: ({ column }) => <DataGridSortHeader 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>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Remote AS",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Описание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||
meta: {
|
||||
headerTitle: "Состояние",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesRx",
|
||||
accessorKey: "prefixesRx",
|
||||
header: ({ column }) => <DataGridSortHeader 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>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↓",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesTx",
|
||||
accessorKey: "prefixesTx",
|
||||
header: ({ column }) => <DataGridSortHeader 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>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Prefixes ↑",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: sessions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет BGP-сессий"
|
||||
description="Измените фильтры или проверьте подключение к роутерам"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function daysLeftBar(days: number, total = 365): number {
|
||||
if (days <= 0) return 0
|
||||
return Math.min(100, Math.round((days / total) * 100))
|
||||
}
|
||||
|
||||
function CertificateExpandedDetail({ cert }: { cert: CertificateDto }) {
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
|
||||
return (
|
||||
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Key size</p>
|
||||
<p className="font-mono font-medium">{cert.keySize} bit</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Действителен с</p>
|
||||
<p className="font-mono">{cert.validFrom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => (
|
||||
<span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{s}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Trusted</p>
|
||||
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
|
||||
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-4">
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden max-w-xs">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
cert.daysLeft < 0 || cert.daysLeft <= 7
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 30
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificateExpandedDetail, daysLeftBar }
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import {
|
||||
CertificateExpandedDetail,
|
||||
daysLeftBar,
|
||||
} from "@/components/data-grids/certificate-expanded-detail"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldOffIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertStatus,
|
||||
{ label: string; icon: ReactNode; badge: string; row: string }
|
||||
> = {
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon className="size-4 text-red-500" />,
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
row: "bg-red-500/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
|
||||
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
row: "bg-amber-500/5",
|
||||
},
|
||||
}
|
||||
|
||||
function daysLeftColor(days: number): string {
|
||||
if (days < 0) return "text-red-500"
|
||||
if (days <= 7) return "text-red-500"
|
||||
if (days <= 30) return "text-amber-500"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function CertDaysCell({ cert }: { cert: CertificateDto }) {
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
cert.daysLeft < 0 || cert.daysLeft <= 7
|
||||
? "bg-red-500"
|
||||
: cert.daysLeft <= 30
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface CertificatesDataGridProps {
|
||||
certificates: CertificateDto[]
|
||||
serverMap: Map<string, Server>
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function CertificatesDataGrid({ certificates, serverMap, isLoading }: CertificatesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<CertificateDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сертификат" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="shrink-0">{cfg.icon}</span>
|
||||
<span className="font-medium text-sm truncate" title={cert.name}>
|
||||
{cert.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate" title={cert.commonName}>
|
||||
{cert.commonName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сертификат",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: CertificateDto) => <CertificateExpandedDetail cert={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "serverId",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => {
|
||||
const cert = row.original
|
||||
const server = serverMap.get(cert.serverId)
|
||||
return server ? (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate">{server.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="font-mono truncate">{cert.serverName ?? cert.serverId}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "issuedBy",
|
||||
accessorKey: "issuedBy",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Издатель" />,
|
||||
cell: ({ row }) => (
|
||||
<p className="text-xs text-muted-foreground truncate min-w-0" title={row.original.issuedBy}>
|
||||
{row.original.issuedBy}
|
||||
</p>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Издатель",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "daysLeft",
|
||||
accessorKey: "daysLeft",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Срок" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<CertDaysCell cert={row.original} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Срок",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
accessorKey: "usage",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Использование</span>,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1 min-w-0 overflow-hidden">
|
||||
{row.original.usage.map((u) => (
|
||||
<span
|
||||
key={u}
|
||||
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border"
|
||||
>
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Использование",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => {
|
||||
const cfg = STATUS_CONFIG[row.original.status]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
cfg.badge,
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[serverMap],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: certificates,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (!isLoading && certificates.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет сертификатов"
|
||||
description="Выпустите или импортируйте сертификат"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={certificates.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { CertificatesDataGrid, STATUS_CONFIG, type CertificatesDataGridProps }
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type CommunityType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
export interface CommunityRow {
|
||||
id: string
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
type: CommunityType
|
||||
filterIds: string[]
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<CommunityType, string> = {
|
||||
standard: "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise": "No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
custom: "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<CommunityRow["action"], string> = {
|
||||
permit: "Permit",
|
||||
deny: "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
metric: "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<CommunityRow["action"], string> = {
|
||||
permit: "text-emerald-500",
|
||||
deny: "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
metric: "text-amber-500",
|
||||
}
|
||||
|
||||
interface CommunitiesDataGridProps {
|
||||
communities: CommunityRow[]
|
||||
selectedId?: string | null
|
||||
copiedValue?: string | null
|
||||
onSelect: (community: CommunityRow) => void
|
||||
onCopy: (value: string) => void
|
||||
}
|
||||
|
||||
function CommunitiesDataGrid({
|
||||
communities,
|
||||
selectedId,
|
||||
copiedValue,
|
||||
onSelect,
|
||||
onCopy,
|
||||
}: CommunitiesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<CommunityRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "value",
|
||||
accessorKey: "value",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Community" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
data-community-disabled={!c.enabled ? true : undefined}
|
||||
>
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCopy(c.value)
|
||||
}}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copiedValue === c.value ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
<CopyIcon className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Community",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Имя / описание" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-xs">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{row.original.description}</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Имя / описание",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TYPE_LABELS[row.original.type]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Действие" />,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original
|
||||
return (
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}
|
||||
{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Действие",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixCount",
|
||||
accessorKey: "prefixCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Маршрутов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums text-right block">
|
||||
{row.original.prefixCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Маршрутов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverCount",
|
||||
accessorKey: "serverCount",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Серверов" className="ml-auto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.serverCount.toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Серверов",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: () => <span className="sr-only">Детали</span>,
|
||||
enableSorting: false,
|
||||
cell: () => <ChevronRightIcon className="size-4 text-muted-foreground/40" />,
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[copiedValue, onCopy],
|
||||
)
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => (selectedId ? { [selectedId]: true } : {}),
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: communities,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
state: { rowSelection },
|
||||
})
|
||||
|
||||
if (communities.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TagIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={communities.length}
|
||||
onRowClick={(row) => onSelect(row)}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn(
|
||||
"group/row",
|
||||
"data-[state=selected]:bg-primary/5",
|
||||
"[&:has([data-community-disabled=true])]:opacity-50",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CommunitiesDataGrid,
|
||||
type CommunitiesDataGridProps,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
ACTION_COLOR,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface CompactDataGridColumn<T> {
|
||||
id: string
|
||||
header: string
|
||||
accessorKey?: keyof T & string
|
||||
cell?: (row: T) => ReactNode
|
||||
enableSorting?: boolean
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
interface CompactDataGridProps<T extends { id: string }> {
|
||||
data: T[]
|
||||
columns: CompactDataGridColumn<T>[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onRowClick?: (row: T) => void
|
||||
getExpandedContent?: (row: T) => ReactNode
|
||||
}
|
||||
|
||||
function CompactDataGrid<T extends { id: string }>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
onRowClick,
|
||||
getExpandedContent,
|
||||
}: CompactDataGridProps<T>) {
|
||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||
() =>
|
||||
columns.map((col, index) => ({
|
||||
id: col.id,
|
||||
accessorKey: col.accessorKey ?? col.id,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title={col.header} className={index === 0 ? "ml-1" : undefined} />
|
||||
),
|
||||
cell: ({ row }) => (col.cell ? col.cell(row.original) : String(row.getValue(col.id) ?? "—")),
|
||||
enableSorting: col.enableSorting !== false,
|
||||
meta: {
|
||||
headerTitle: col.header,
|
||||
headerClassName:
|
||||
col.headerClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
cellClassName:
|
||||
col.cellClassName ??
|
||||
(index === 0
|
||||
? DATA_GRID_CELL_PAD_FIRST
|
||||
: index === columns.length - 1
|
||||
? DATA_GRID_CELL_PAD_LAST
|
||||
: DATA_GRID_CELL_PAD),
|
||||
...(getExpandedContent && index === 0
|
||||
? { expandedContent: getExpandedContent }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
[columns, getExpandedContent],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: columnDefs,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(getExpandedContent ? { getExpandedRowModel: getExpandedRowModel() } : {}),
|
||||
getRowId: (row) => row.id,
|
||||
...(getExpandedContent ? { getRowCanExpand: () => true } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={
|
||||
onRowClick ??
|
||||
(getExpandedContent
|
||||
? (row) => table.getRow(row.id).toggleExpanded()
|
||||
: undefined)
|
||||
}
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { CompactDataGrid, type CompactDataGridProps }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user