Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1a9124f3d | ||
|
|
009011a917 | ||
|
|
399871f4f9 | ||
|
|
158fc36294 | ||
|
|
7dc4836c71 | ||
|
|
efc3812e12 | ||
|
|
8d5fd84962 | ||
|
|
f69e65b014 | ||
|
|
49d14a00af | ||
|
|
9ab2418a5f | ||
|
|
63aa9d424b | ||
|
|
43ab17b253 | ||
|
|
4b8cf83ee9 | ||
|
|
cdaf3deb03 | ||
|
|
3ce4179f3c | ||
|
|
1a47905beb | ||
|
|
b51d979a1c | ||
|
|
bc01da1708 | ||
|
|
500a543045 | ||
|
|
60024facab | ||
|
|
53ea0e74a3 | ||
|
|
6c8ca17590 | ||
|
|
d62d212c86 | ||
|
|
bfceb07a23 | ||
|
|
e7a8ad0910 |
@@ -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
|
||||
@@ -7,9 +7,10 @@ alwaysApply: true
|
||||
|
||||
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
|
||||
|
||||
## Язык
|
||||
## Обязательно
|
||||
|
||||
- После `:` — **только русский**. Английский в subject/body запрещён.
|
||||
- Subject и body — **только на русском** (после `:` в conventional commit).
|
||||
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
|
||||
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
|
||||
|
||||
## Тип: сначала не `feat`
|
||||
|
||||
@@ -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`, раздел «Запуск».
|
||||
|
||||
@@ -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"
|
||||
@@ -7,5 +7,10 @@
|
||||
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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
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 { 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 useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -28,7 +31,9 @@ export default function AsnsPage() {
|
||||
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>
|
||||
</>
|
||||
@@ -57,6 +62,7 @@ export default function AsnsPage() {
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
searchKeys={["asn", "org", "prefixes"]}
|
||||
columns={[
|
||||
@@ -106,6 +112,16 @@ export default function AsnsPage() {
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+220
-207
@@ -2,6 +2,10 @@
|
||||
|
||||
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"
|
||||
@@ -20,54 +24,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 +85,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 +110,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 +166,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 +328,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" />Новый бэкап
|
||||
@@ -388,81 +397,27 @@ 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>
|
||||
<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}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -480,11 +435,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 +449,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 +466,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 +499,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 +516,7 @@ export default function BackupsPage() {
|
||||
{ value: "backup", label: ".backup" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -572,7 +527,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 +538,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 +591,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 +666,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 +677,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>
|
||||
)
|
||||
}
|
||||
|
||||
+77
-293
@@ -2,6 +2,13 @@
|
||||
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -17,49 +24,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 +210,6 @@ function TypeBadge({ type }: { type: BgpType }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CapChip({ cap }: { cap: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
|
||||
{cap}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtNum(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
@@ -254,108 +217,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,158 +258,81 @@ 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>
|
||||
<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} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
@@ -826,8 +610,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)
|
||||
|
||||
+1045
-270
File diff suppressed because it is too large
Load Diff
@@ -289,8 +289,8 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
@@ -306,9 +306,10 @@ export default function DashboardPage() {
|
||||
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||
|
||||
const probeServerCatalog = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockServers
|
||||
return liveServersResolved ?? []
|
||||
}, [isLive, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveServersResolved])
|
||||
|
||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
@@ -523,13 +524,15 @@ export default function DashboardPage() {
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbesTable = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null && probesLoading) return []
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
|
||||
const probesSubtitle = useMemo(() => {
|
||||
if (!prefsHydrated) return "Загрузка…"
|
||||
if (!isLive) {
|
||||
const starred = mockActiveProbes.length
|
||||
return starred > 0
|
||||
@@ -541,10 +544,13 @@ export default function DashboardPage() {
|
||||
return n > 0
|
||||
? `${n} на дашборде · последний час (API)`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
}, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
|
||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||
const serverStatusModel = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -571,9 +577,12 @@ export default function DashboardPage() {
|
||||
servers,
|
||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||
}
|
||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||||
}, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError])
|
||||
|
||||
const latencyChartBlock = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -612,7 +621,7 @@ export default function DashboardPage() {
|
||||
subtitle:
|
||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||
}
|
||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
|
||||
const dashboardKpi = useMemo(() => {
|
||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||||
@@ -620,6 +629,24 @@ export default function DashboardPage() {
|
||||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||||
|
||||
const loadingKpi = (sparkColor: string) => ({
|
||||
value: "—",
|
||||
unit: undefined as string | undefined,
|
||||
delta: "Загрузка…",
|
||||
deltaDir: "up" as const,
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor,
|
||||
})
|
||||
|
||||
if (!prefsHydrated) {
|
||||
return {
|
||||
servers: loadingKpi("var(--chart-line-1)"),
|
||||
filters: loadingKpi("var(--chart-line-2)"),
|
||||
bgp: loadingKpi("var(--chart-line-4)"),
|
||||
alerts: loadingKpi("var(--chart-5)"),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLive) {
|
||||
const totalSrv = mockServers.length
|
||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||
@@ -664,7 +691,8 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadingBlock = probesLoading && liveProbes === null
|
||||
const liveDataPending = liveServersResolved === null || liveProbes === null
|
||||
const loadingBlock = liveDataPending || (probesLoading && liveProbes === null)
|
||||
const srvList = liveServersResolved ?? []
|
||||
const totalSrv = srvList.length
|
||||
const onlineSrv = srvList.filter((s) => s.status === "online").length
|
||||
@@ -730,7 +758,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">
|
||||
|
||||
+378
-104
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -24,13 +25,15 @@ import {
|
||||
type SchedulerRunRowDto,
|
||||
type UptimeSettingsDto,
|
||||
} from "@/lib/scheduler-settings"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { requestJson, ApiClientError } from "@/shared/api/http-client"
|
||||
import {
|
||||
parseSchedulerRunSnapshot,
|
||||
type AlertEngineRuleDiagSnapshot,
|
||||
type AlertEngineRunSnapshot,
|
||||
type GreBgpSnapshotRunSnapshot,
|
||||
type InternetPathRunSnapshot,
|
||||
type CertificatesRenewRunSnapshot,
|
||||
type BackupsRunSnapshot,
|
||||
type PingRunSnapshot,
|
||||
type ResourcesRunSnapshot,
|
||||
type SchedulerRunSnapshot,
|
||||
@@ -56,26 +59,25 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 extractApiError(reason: unknown): string {
|
||||
if (reason instanceof ApiClientError) return reason.message
|
||||
if (reason instanceof Error) return reason.message
|
||||
return String(reason)
|
||||
}
|
||||
|
||||
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
|
||||
return result.status === "fulfilled" ? result.value : null
|
||||
}
|
||||
|
||||
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
|
||||
const errors: string[] = []
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const result = results[i]
|
||||
if (result.status === "rejected") {
|
||||
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
@@ -464,6 +466,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "certificates_renew") {
|
||||
const c = snap as CertificatesRenewRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{c.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Проверено</dt>
|
||||
<dd className="font-mono font-medium">{c.checked}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Обновлено</dt>
|
||||
<dd className="font-mono font-medium">{c.renewed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Пропущено</dt>
|
||||
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Ошибки</dt>
|
||||
<dd className="font-mono font-medium">{c.errors.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{c.errors.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
{c.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "backups") {
|
||||
const b = snap as BackupsRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Создано</dt>
|
||||
<dd className="font-mono font-medium">{b.created}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Ошибки</dt>
|
||||
<dd className="font-mono font-medium">{b.failures}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Удалено старых</dt>
|
||||
<dd className="font-mono font-medium">{b.pruned}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{b.errors?.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
{b.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||
@@ -660,9 +729,41 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
)
|
||||
}
|
||||
|
||||
type SchedulerToggleOverrides = {
|
||||
trafficEnabled?: boolean
|
||||
serversApiEnabled?: boolean
|
||||
resourcesEnabled?: boolean
|
||||
pingEnabled?: boolean
|
||||
speedEnabled?: boolean
|
||||
internetPathEnabled?: boolean
|
||||
certRenewEnabled?: boolean
|
||||
}
|
||||
|
||||
function applySchedulerJobsToDrafts(
|
||||
jobs: SchedulerJobStatusDto[],
|
||||
setters: {
|
||||
setDraftTrafficEnabled: (value: boolean) => void
|
||||
setDraftServersApiEnabled: (value: boolean) => void
|
||||
setDraftResourcesEnabled: (value: boolean) => void
|
||||
setDraftPingEnabled: (value: boolean) => void
|
||||
setDraftSpeedEnabled: (value: boolean) => void
|
||||
setDraftInternetPathEnabled: (value: boolean) => void
|
||||
setDraftCertRenewEnabled: (value: boolean) => void
|
||||
},
|
||||
) {
|
||||
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
|
||||
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
|
||||
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
|
||||
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
|
||||
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
|
||||
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
|
||||
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
|
||||
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
|
||||
}
|
||||
|
||||
export default function DataCollectionPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
@@ -683,6 +784,9 @@ export default function DataCollectionPage() {
|
||||
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
||||
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
||||
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
|
||||
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
|
||||
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
||||
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
||||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
||||
@@ -714,32 +818,79 @@ export default function DataCollectionPage() {
|
||||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||
: "?limit=80"
|
||||
const [traffic, serversApi, uptime, internetPath, runsRes] = await Promise.all([
|
||||
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||
])
|
||||
setTrafficCollector(traffic)
|
||||
setServersApiCollector(serversApi)
|
||||
setUptimeCollector(uptime)
|
||||
setInternetPathCollector(internetPath)
|
||||
setSchedulerRuns(runsRes.runs ?? [])
|
||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||
setDraftTrafficEnabled(!!traffic.enabled)
|
||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||
|
||||
const loadErrors = collectSettledErrors(
|
||||
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
|
||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
|
||||
)
|
||||
if (loadErrors.length > 0) {
|
||||
setCollectorError(loadErrors.join("; "))
|
||||
}
|
||||
|
||||
const traffic = readSettled(trafficRes)
|
||||
if (traffic) {
|
||||
setTrafficCollector(traffic)
|
||||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||||
setDraftTrafficEnabled(!!traffic.enabled)
|
||||
}
|
||||
|
||||
const serversApi = readSettled(serversApiRes)
|
||||
if (serversApi) {
|
||||
setServersApiCollector(serversApi)
|
||||
setDraftServersApiEnabled(!!serversApi.enabled)
|
||||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||||
}
|
||||
|
||||
const uptime = readSettled(uptimeRes)
|
||||
if (uptime) {
|
||||
setUptimeCollector(uptime)
|
||||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||
if (uptime.scheduler?.jobs?.length) {
|
||||
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
|
||||
setDraftTrafficEnabled,
|
||||
setDraftServersApiEnabled,
|
||||
setDraftResourcesEnabled,
|
||||
setDraftPingEnabled,
|
||||
setDraftSpeedEnabled,
|
||||
setDraftInternetPathEnabled,
|
||||
setDraftCertRenewEnabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const internetPath = readSettled(internetPathRes)
|
||||
if (internetPath) {
|
||||
setInternetPathCollector(internetPath)
|
||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||
}
|
||||
|
||||
const certRenew = readSettled(certRenewRes)
|
||||
if (certRenew) {
|
||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||
}
|
||||
|
||||
const runsPayload = readSettled(runsRes)
|
||||
if (runsPayload) {
|
||||
setSchedulerRuns(runsPayload.runs ?? [])
|
||||
}
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||
} finally {
|
||||
@@ -747,6 +898,138 @@ export default function DataCollectionPage() {
|
||||
}
|
||||
}, [apiFetch, isLive, runFilterJobKey])
|
||||
|
||||
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
|
||||
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
|
||||
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
|
||||
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
|
||||
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
|
||||
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
|
||||
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
|
||||
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
|
||||
|
||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
||||
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
|
||||
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
|
||||
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
|
||||
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
||||
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
||||
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
||||
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
|
||||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
||||
|
||||
const saveResults = await Promise.allSettled([
|
||||
apiFetch("/api/traffic/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: trafficEnabled,
|
||||
intervalSec: tInt,
|
||||
retentionDays: tRet,
|
||||
}),
|
||||
}),
|
||||
apiFetch("/api/servers-api-ping/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: serversApiEnabled,
|
||||
intervalSec: sApiInt,
|
||||
}),
|
||||
}),
|
||||
apiFetch("/api/uptime/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
resourcesEnabled,
|
||||
pingEnabled,
|
||||
speedEnabled,
|
||||
intervalSec: uRes,
|
||||
probeIntervalSec: uPing,
|
||||
speedIntervalSec: uSpd,
|
||||
retentionDays: uRet,
|
||||
}),
|
||||
}),
|
||||
apiFetch("/api/internet-path/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: internetPathEnabled,
|
||||
intervalSec: ipInt,
|
||||
}),
|
||||
}),
|
||||
apiFetch("/api/certificates/renew-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: certRenewEnabled,
|
||||
intervalSec: certRenewInt,
|
||||
renewBeforeDays,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
const saveErrors = collectSettledErrors(
|
||||
saveResults,
|
||||
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
|
||||
)
|
||||
if (saveErrors.length > 0) {
|
||||
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
|
||||
}
|
||||
}, [
|
||||
apiFetch,
|
||||
certRenewIntervalDraft,
|
||||
draftCertRenewEnabled,
|
||||
draftInternetPathEnabled,
|
||||
draftPingEnabled,
|
||||
draftResourcesEnabled,
|
||||
draftServersApiEnabled,
|
||||
draftSpeedEnabled,
|
||||
draftTrafficEnabled,
|
||||
internetPathIntervalDraft,
|
||||
renewBeforeDaysDraft,
|
||||
serversApiIntervalDraft,
|
||||
trafficIntervalDraft,
|
||||
trafficRetentionDraft,
|
||||
uptimeIntervalDraft,
|
||||
uptimeResourceIntervalDraft,
|
||||
uptimeRetentionDraft,
|
||||
uptimeSpeedIntervalDraft,
|
||||
])
|
||||
|
||||
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
|
||||
const overrides: SchedulerToggleOverrides = {}
|
||||
if (jobKey === "traffic") {
|
||||
setDraftTrafficEnabled(nextEnabled)
|
||||
overrides.trafficEnabled = nextEnabled
|
||||
} else if (jobKey === "servers_rest_ping") {
|
||||
setDraftServersApiEnabled(nextEnabled)
|
||||
overrides.serversApiEnabled = nextEnabled
|
||||
} else if (jobKey === "uptime_resources") {
|
||||
setDraftResourcesEnabled(nextEnabled)
|
||||
overrides.resourcesEnabled = nextEnabled
|
||||
} else if (jobKey === "uptime_ping") {
|
||||
setDraftPingEnabled(nextEnabled)
|
||||
overrides.pingEnabled = nextEnabled
|
||||
} else if (jobKey === "uptime_speed") {
|
||||
setDraftSpeedEnabled(nextEnabled)
|
||||
overrides.speedEnabled = nextEnabled
|
||||
} else if (jobKey === "certificates_renew") {
|
||||
setDraftCertRenewEnabled(nextEnabled)
|
||||
overrides.certRenewEnabled = nextEnabled
|
||||
} else if (jobKey === "internet_path") {
|
||||
setDraftInternetPathEnabled(nextEnabled)
|
||||
overrides.internetPathEnabled = nextEnabled
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
setCollectorError(null)
|
||||
setSchedulerSaveBusy(true)
|
||||
try {
|
||||
await persistSchedulerDrafts(overrides)
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
} finally {
|
||||
setSchedulerSaveBusy(false)
|
||||
}
|
||||
}, [loadCollectors, persistSchedulerDrafts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
setTrafficCollector(null)
|
||||
@@ -775,20 +1058,26 @@ export default function DataCollectionPage() {
|
||||
}
|
||||
|
||||
const enabledJobsCount = useMemo(() => {
|
||||
const jobs = uptimeCollector?.scheduler?.jobs
|
||||
if (jobs?.length) return jobs.filter((job) => job.enabled).length
|
||||
|
||||
let n = draftTrafficEnabled ? 1 : 0
|
||||
if (draftServersApiEnabled) n += 1
|
||||
if (draftResourcesEnabled) n += 1
|
||||
if (draftPingEnabled) n += 1
|
||||
if (draftSpeedEnabled) n += 1
|
||||
if (draftInternetPathEnabled) n += 1
|
||||
if (draftCertRenewEnabled) n += 1
|
||||
return n
|
||||
}, [
|
||||
draftCertRenewEnabled,
|
||||
draftInternetPathEnabled,
|
||||
draftPingEnabled,
|
||||
draftResourcesEnabled,
|
||||
draftServersApiEnabled,
|
||||
draftSpeedEnabled,
|
||||
draftTrafficEnabled,
|
||||
uptimeCollector?.scheduler?.jobs,
|
||||
])
|
||||
|
||||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||||
@@ -805,7 +1094,9 @@ export default function DataCollectionPage() {
|
||||
{
|
||||
label: "Включено задач",
|
||||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||||
sub: "По переключателям на этой странице (до сохранения)",
|
||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||
? "По сохранённым задачам планировщика"
|
||||
: "По переключателям на этой странице",
|
||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
@@ -872,7 +1163,18 @@ export default function DataCollectionPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
|
||||
{!isLive && (
|
||||
{!prefsHydrated && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Загрузка настроек подключения</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Читаем режим данных и адрес API из локальных настроек.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{prefsHydrated && !isLive && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
||||
@@ -917,7 +1219,7 @@ export default function DataCollectionPage() {
|
||||
<CardHeader className="border-b border-border pb-4">
|
||||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
|
||||
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pb-0">
|
||||
@@ -936,20 +1238,22 @@ export default function DataCollectionPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine"
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const en = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
? draftTrafficEnabled
|
||||
: jobKey === "servers_rest_ping"
|
||||
? draftServersApiEnabled
|
||||
: jobKey === "uptime_resources"
|
||||
: jobKey === "uptime_resources"
|
||||
? draftResourcesEnabled
|
||||
: jobKey === "uptime_ping"
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: draftInternetPathEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const iv = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
@@ -962,7 +1266,9 @@ export default function DataCollectionPage() {
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const setIv = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
@@ -975,7 +1281,9 @@ export default function DataCollectionPage() {
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defSec = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
@@ -988,7 +1296,9 @@ export default function DataCollectionPage() {
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: 300
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
return (
|
||||
<tr key={jobKey} className="hover:bg-muted/40">
|
||||
<td className="px-5 py-3 align-top">
|
||||
@@ -1000,16 +1310,12 @@ export default function DataCollectionPage() {
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center align-top">
|
||||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||||
<Toggle
|
||||
<FormToggle
|
||||
checked={en}
|
||||
disabled={fixedSchedule || schedulerSaveBusy}
|
||||
onChange={(v) => {
|
||||
if (fixedSchedule) return
|
||||
if (jobKey === "traffic") setDraftTrafficEnabled(v)
|
||||
else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v)
|
||||
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
|
||||
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
|
||||
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
|
||||
else setDraftInternetPathEnabled(v)
|
||||
if (fixedSchedule || schedulerSaveBusy) return
|
||||
void handleJobEnabledChange(jobKey, v)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
@@ -1111,6 +1417,15 @@ export default function DataCollectionPage() {
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
||||
<Input
|
||||
value={renewBeforeDaysDraft}
|
||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
|
||||
@@ -1122,48 +1437,7 @@ export default function DataCollectionPage() {
|
||||
setSchedulerSaveBusy(true)
|
||||
setCollectorError(null)
|
||||
try {
|
||||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
||||
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
|
||||
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
|
||||
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
|
||||
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
||||
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
||||
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
||||
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
|
||||
await apiFetch("/api/traffic/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: draftTrafficEnabled,
|
||||
intervalSec: tInt,
|
||||
retentionDays: tRet,
|
||||
}),
|
||||
})
|
||||
await apiFetch("/api/servers-api-ping/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: draftServersApiEnabled,
|
||||
intervalSec: sApiInt,
|
||||
}),
|
||||
})
|
||||
await apiFetch("/api/uptime/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
resourcesEnabled: draftResourcesEnabled,
|
||||
pingEnabled: draftPingEnabled,
|
||||
speedEnabled: draftSpeedEnabled,
|
||||
intervalSec: uRes,
|
||||
probeIntervalSec: uPing,
|
||||
speedIntervalSec: uSpd,
|
||||
retentionDays: uRet,
|
||||
}),
|
||||
})
|
||||
await apiFetch("/api/internet-path/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: draftInternetPathEnabled,
|
||||
intervalSec: ipInt,
|
||||
}),
|
||||
})
|
||||
await persistSchedulerDrafts()
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
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 { 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 useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
@@ -28,7 +31,9 @@ export default function DomainsPage() {
|
||||
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>
|
||||
</>
|
||||
@@ -57,6 +62,7 @@ export default function DomainsPage() {
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
@@ -109,6 +115,16 @@ export default function DomainsPage() {
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+18
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
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 { Card } from "@/components/ui/card"
|
||||
@@ -2062,22 +2063,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">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { firewallRules, type FirewallRule } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -341,22 +342,6 @@ 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 (
|
||||
@@ -375,29 +360,6 @@ function ChainBadge({ chain }: { chain: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, required, children }: {
|
||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
}) {
|
||||
@@ -507,67 +469,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 +540,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 +560,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 +1001,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 +1016,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 +1029,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 +1132,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>
|
||||
@@ -1783,7 +1745,7 @@ function RulesTable({ rules, onToggle, onEdit }: {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Toggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||||
<FormToggle checked={r.enabled} onChange={() => onToggle(r.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<DropdownMenu>
|
||||
|
||||
+49
-100
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
|
||||
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
|
||||
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
@@ -138,58 +139,6 @@ function IpsecBadge({ secured }: { secured: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, required, children }: {
|
||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
|
||||
>
|
||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Live API (как на /filters) ─────────────────────────────────────────────
|
||||
|
||||
interface BackendServer {
|
||||
@@ -272,8 +221,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[]>([])
|
||||
@@ -838,51 +787,51 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex 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 +842,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 +856,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 +904,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 +930,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 +955,18 @@ export default function GrePage() {
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex 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,22 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
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 { 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)
|
||||
|
||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
|
||||
const rows = useMemo(() => {
|
||||
@@ -29,7 +31,9 @@ export default function IpRangesPage() {
|
||||
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>
|
||||
</>
|
||||
@@ -58,6 +62,7 @@ export default function IpRangesPage() {
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
@@ -108,6 +113,16 @@ export default function IpRangesPage() {
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>>({})
|
||||
|
||||
@@ -1227,8 +1227,8 @@ export default function OspfPage() {
|
||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -1291,6 +1291,16 @@ export default function OspfPage() {
|
||||
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
||||
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
||||
}
|
||||
if (isLive) {
|
||||
return {
|
||||
items: [],
|
||||
neighbors: [],
|
||||
graphNodes: [],
|
||||
graphEdges: [],
|
||||
routerIds: {},
|
||||
bfdSessions: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: MOCK_ITEMS,
|
||||
neighbors: MOCK_NEIGHBORS,
|
||||
@@ -1318,6 +1328,7 @@ export default function OspfPage() {
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
if (isLive) return []
|
||||
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
|
||||
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
|
||||
const siteCountry: Record<string, string> = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -353,17 +354,6 @@ function NativeSelect({ value, onChange, children, className }: {
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button onClick={() => onChange(!checked)}
|
||||
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted-foreground/30")}>
|
||||
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5")} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||
}
|
||||
@@ -593,7 +583,7 @@ function ScheduleTab({
|
||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
!rule.enabled && "opacity-50",
|
||||
)}>
|
||||
<Toggle checked={rule.enabled}
|
||||
<FormToggle checked={rule.enabled}
|
||||
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
|
||||
<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>
|
||||
@@ -729,10 +719,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 +1105,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,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -148,32 +149,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,
|
||||
}: {
|
||||
@@ -370,9 +345,9 @@ function RouteSheet({
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex 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 +371,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 +442,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 +473,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("")
|
||||
|
||||
@@ -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">
|
||||
@@ -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,7 @@
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
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 +246,6 @@ function LossChip({ loss }: { loss: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button type="button" onClick={() => onChange(!checked)}
|
||||
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
||||
checked ? "bg-primary" : "bg-input")}>
|
||||
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0")} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
return (
|
||||
<Input type="number" value={value} min={min} max={max}
|
||||
@@ -1167,7 +1157,7 @@ export default function RouteOptimizerPage() {
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
|
||||
<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 && (
|
||||
<>
|
||||
|
||||
+159
-472
@@ -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"
|
||||
@@ -30,56 +35,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 +71,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 +159,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 +213,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 +251,7 @@ export default function ServersPage() {
|
||||
})
|
||||
.catch(() => {
|
||||
setBackendOk(false)
|
||||
setServerList(initialServers)
|
||||
setServerList([])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, backendUrl])
|
||||
@@ -368,6 +261,7 @@ export default function ServersPage() {
|
||||
function openAdd() {
|
||||
setSheetMode("add"); setEditingId(null)
|
||||
setForm(defaultForm); setTestState("idle"); setTestMsg("")
|
||||
setSheetStep(1)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
@@ -381,7 +275,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 +443,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,272 +510,34 @@ 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 className="overflow-hidden py-0 gap-0">
|
||||
<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}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -893,18 +550,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 +598,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 +623,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 +670,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 +690,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 +706,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 +728,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 +739,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>
|
||||
|
||||
@@ -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"
|
||||
@@ -162,17 +164,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 +404,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 +444,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>
|
||||
)}
|
||||
|
||||
@@ -660,7 +651,7 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
</span>
|
||||
|
||||
{/* active toggle */}
|
||||
<Toggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||
<FormToggle checked={su.active} onChange={() => toggleSubUser(su.id)} />
|
||||
|
||||
{/* delete */}
|
||||
<button onClick={() => removeSubUser(su.id)}
|
||||
@@ -775,22 +766,6 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{children}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircleIcon className="size-3" />{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||
|
||||
function DatabaseRestoreConfirm({
|
||||
@@ -900,7 +875,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 +1006,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 +1204,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 +1344,7 @@ export default function SettingsPage() {
|
||||
label="Подставлять данные EvoBGP"
|
||||
description="На страницах Домены, IP-диапазоны, ASN и Communities вместо моков из lib/data"
|
||||
>
|
||||
<Toggle
|
||||
<FormToggle
|
||||
checked={evoEnabledDraft}
|
||||
onChange={(v) => setEvoEnabledDraft(v)}
|
||||
/>
|
||||
@@ -1496,15 +1456,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 +1473,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>
|
||||
@@ -1741,7 +1701,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 +1723,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 +1813,6 @@ export default function SettingsPage() {
|
||||
onCancel={() => {
|
||||
if (dbRestoreBusy) return
|
||||
setDbRestoreFile(null)
|
||||
if (dbRestoreInputRef.current) dbRestoreInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1864,6 +1823,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")
|
||||
|
||||
+34
-96
@@ -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"
|
||||
@@ -212,21 +213,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 +253,6 @@ function StatChip({
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">
|
||||
{label}
|
||||
{hint && <span className="font-normal text-muted-foreground ml-1">{hint}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Активный интерфейс RouterOS: не disabled и running */
|
||||
function isActiveRouterOsInterface(i: { running?: boolean; disabled?: boolean }): boolean {
|
||||
return i.running === true && i.disabled !== true
|
||||
@@ -323,36 +297,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,
|
||||
@@ -1293,15 +1237,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 +1278,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 +2345,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">
|
||||
@@ -2830,7 +2768,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 +2946,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 +2959,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 +2970,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 +3000,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 +3011,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 +3113,7 @@ export default function UptimePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
<FormField
|
||||
label="Источник (кто пингует)"
|
||||
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||
>
|
||||
@@ -3184,9 +3122,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 +3132,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">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { servers } from "@/lib/data"
|
||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
@@ -410,11 +411,12 @@ export default function WireGuardPage() {
|
||||
</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>
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или проверьте поиск"
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
) : (
|
||||
filtered.map((iface) => (
|
||||
<IfaceRow
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
export const maxDuration = 600
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return proxyBackendRequest(request, "/api/system/database/backup", {
|
||||
method: "GET",
|
||||
forwardRequestBody: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import { proxyBackendRequest } from "@/lib/proxy-backend-request"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
export const maxDuration = 600
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return proxyBackendRequest(request, "/api/system/database/restore", { method: "POST" })
|
||||
}
|
||||
@@ -39,6 +39,15 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-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%);
|
||||
|
||||
@@ -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" })
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridColumnHeader,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
interface BackupsDataGridProps {
|
||||
backups: Backup[]
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
function BackupsDataGrid({
|
||||
backups,
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: BackupsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Backup>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "filename",
|
||||
accessorKey: "filename",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
accessorKey: "server",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.server}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
accessorKey: "size",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Размер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => {
|
||||
const kind = row.original.kind
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded border font-medium",
|
||||
kind === "manual"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{kind === "auto" ? "авто" : "вручную"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
accessorKey: "notes",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Заметки" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
|
||||
{row.original.notes || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "created",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Создан" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">{row.original.created}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Скачать"
|
||||
onClick={() => onDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Восстановить"
|
||||
onClick={() => onRestore(b)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive hover:text-destructive"
|
||||
title="Удалить"
|
||||
onClick={() => onDelete(b.id)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
},
|
||||
],
|
||||
[onDelete, onDownload, onRestore],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: backups,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<HardDriveIcon className="size-4" />}
|
||||
title="Нет бэкапов"
|
||||
description="Создайте первый бэкап вручную или настройте расписание"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid table={table} recordCount={backups.length} isLoading={false}>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { BackupsDataGrid, type BackupsDataGridProps }
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ClipboardCopyIcon } from "lucide-react"
|
||||
import type { BgpSessionRow } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum, rscBgpSnippet } from "@/lib/bgp/helpers"
|
||||
|
||||
function CapChip({ cap }: { cap: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
|
||||
{cap}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
|
||||
const max = Math.max(rx, 1)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
||||
{[
|
||||
{ label: "Получено", val: rx, color: "bg-[var(--chart-rx)]", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-[var(--chart-1)]", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-[var(--chart-tx)]", w: Math.min(tx / max, 1) },
|
||||
].map((r) => (
|
||||
<div key={r.label} className="flex items-center gap-2">
|
||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full", r.color)}
|
||||
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-14 text-right tabular-nums">{fmtBgpNum(r.val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BgpSessionDetail({ session }: { session: BgpSessionRow }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(rscBgpSnippet(session)).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ label: "Router ID", value: session.routerId },
|
||||
{ label: "Hold / KA", value: `${session.holdTime}s / ${session.keepalive}s` },
|
||||
{ label: "AFI/SAFI", value: session.afi },
|
||||
{
|
||||
label: "Сообщения ↓/↑",
|
||||
value: `${fmtBgpNum(session.inputMessages)} / ${fmtBgpNum(session.outputMessages)}`,
|
||||
},
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||||
<p className="text-xs font-mono font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{session.state === "Established" && (
|
||||
<div className="mb-4">
|
||||
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">
|
||||
Префиксы
|
||||
</p>
|
||||
<PrefixBar
|
||||
rx={session.prefixesRx}
|
||||
tx={session.prefixesTx}
|
||||
active={session.prefixesActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.capabilities.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
|
||||
Capabilities
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{session.capabilities.map((c) => (
|
||||
<CapChip key={c} cap={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{session.lastError && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
|
||||
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
||||
<p className="text-xs font-mono text-red-500">{session.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">
|
||||
RouterOS Export
|
||||
</p>
|
||||
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
|
||||
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
|
||||
{rscBgpSnippet(session)}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
|
||||
copied
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
||||
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<ClipboardCopyIcon className="size-3" />
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BgpSessionDetail }
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridColumnHeader,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { BgpSessionDetail } from "@/components/data-grids/bgp-session-detail"
|
||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { fmtBgpNum } from "@/lib/bgp/helpers"
|
||||
import { ChevronDownIcon, ChevronRightIcon, NetworkIcon } from "lucide-react"
|
||||
|
||||
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||
Established: {
|
||||
bg: "bg-emerald-500/10",
|
||||
text: "text-emerald-600 dark:text-emerald-400",
|
||||
dot: "bg-emerald-500",
|
||||
label: "Established",
|
||||
},
|
||||
Active: {
|
||||
bg: "bg-amber-500/10",
|
||||
text: "text-amber-600 dark:text-amber-400",
|
||||
dot: "bg-amber-500",
|
||||
label: "Active",
|
||||
},
|
||||
Idle: {
|
||||
bg: "bg-slate-500/10",
|
||||
text: "text-slate-500 dark:text-slate-400",
|
||||
dot: "bg-slate-500",
|
||||
label: "Idle",
|
||||
},
|
||||
Connect: {
|
||||
bg: "bg-blue-500/10",
|
||||
text: "text-blue-600 dark:text-blue-400",
|
||||
dot: "bg-blue-500",
|
||||
label: "Connect",
|
||||
},
|
||||
OpenSent: {
|
||||
bg: "bg-violet-500/10",
|
||||
text: "text-violet-600 dark:text-violet-400",
|
||||
dot: "bg-violet-500",
|
||||
label: "OpenSent",
|
||||
},
|
||||
OpenConfirm: {
|
||||
bg: "bg-violet-500/10",
|
||||
text: "text-violet-600 dark:text-violet-400",
|
||||
dot: "bg-violet-500",
|
||||
label: "OpenConfirm",
|
||||
},
|
||||
}
|
||||
|
||||
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
||||
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: BgpState }) {
|
||||
const s = STATE_STYLE[state]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
||||
s.bg,
|
||||
s.text,
|
||||
"border-current/20",
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: BgpType }) {
|
||||
const s = TYPE_STYLE[type]
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||
s.bg,
|
||||
s.text,
|
||||
"border-current/20",
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface BgpSessionsDataGridProps {
|
||||
sessions: BgpSessionRow[]
|
||||
}
|
||||
|
||||
function BgpSessionsDataGrid({ sessions }: BgpSessionsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<BgpSessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "serverLabel",
|
||||
accessorKey: "serverLabel",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роутер" />,
|
||||
cell: ({ row }) => {
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<span className="font-mono whitespace-nowrap">{row.original.serverLabel}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Роутер",
|
||||
expandedContent: (row: BgpSessionRow) => <BgpSessionDetail session={row} />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "peerIp",
|
||||
accessorKey: "peerIp",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Peer IP" />,
|
||||
cell: ({ row }) => <span className="font-mono">{row.original.peerIp}</span>,
|
||||
},
|
||||
{
|
||||
id: "remoteAs",
|
||||
accessorKey: "remoteAs",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Remote AS" />,
|
||||
cell: ({ row }) => {
|
||||
const as = row.original.remoteAs
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
<span>AS{as}</span>
|
||||
{BGP_AS_NAMES[as] && (
|
||||
<span className="text-muted-foreground text-[10px]">{BGP_AS_NAMES[as]}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Описание" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground max-w-[180px] truncate block">
|
||||
{row.original.description}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||
cell: ({ row }) => <StateBadge state={row.original.state} />,
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptime",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Uptime" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{row.original.uptime ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "prefixesRx",
|
||||
accessorKey: "prefixesRx",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↓" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesRx
|
||||
return n > 0 ? (
|
||||
<span className="font-mono tabular-nums text-emerald-600 dark:text-emerald-400 text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prefixesTx",
|
||||
accessorKey: "prefixesTx",
|
||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Prefixes ↑" />,
|
||||
cell: ({ row }) => {
|
||||
const n = row.original.prefixesTx
|
||||
return n > 0 ? (
|
||||
<span className="font-mono tabular-nums text-[var(--chart-tx)] text-right block">
|
||||
{fmtBgpNum(n)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: sessions,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет BGP-сессий"
|
||||
description="Измените фильтры или проверьте подключение к роутерам"
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={sessions.length}
|
||||
isLoading={false}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
>
|
||||
<DataGridContainer>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { BgpSessionsDataGrid, type BgpSessionsDataGridProps }
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RosBadge, rosVer } from "@/components/data-grids/servers-data-grid"
|
||||
import { CheckCircleIcon, XCircleIcon, RefreshCwIcon } from "lucide-react"
|
||||
|
||||
const ROS_FEATURES = [
|
||||
{ name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" },
|
||||
{ name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" },
|
||||
{ name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" },
|
||||
{ name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" },
|
||||
{ name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" },
|
||||
{ name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" },
|
||||
{ name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" },
|
||||
{ name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" },
|
||||
{ name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" },
|
||||
{ name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" },
|
||||
]
|
||||
|
||||
interface ServerExpandedDetailProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
isPolling: boolean
|
||||
onPoll: () => void
|
||||
}
|
||||
|
||||
function ServerExpandedDetail({ server: s, isLive, isPolling, onPoll }: ServerExpandedDetailProps) {
|
||||
const ver = rosVer(s.os)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-5 py-5 bg-muted/20">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
{s.model && s.model !== "—" && (
|
||||
<span className="text-muted-foreground">Модель: <span className="font-mono text-foreground">{s.model}</span></span>
|
||||
)}
|
||||
{s.uptime && (
|
||||
<span className="text-muted-foreground">Uptime: <span className="font-mono text-foreground">{s.uptime}</span></span>
|
||||
)}
|
||||
{s.cpuLoad != null && (
|
||||
<span className="text-muted-foreground">CPU: <span className={cn("font-mono font-semibold", s.cpuLoad > 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}%</span></span>
|
||||
)}
|
||||
{s.asn && (
|
||||
<span className="text-muted-foreground">ASN: <span className="font-mono text-foreground">{s.asn}</span></span>
|
||||
)}
|
||||
{s.ipv6Address && (
|
||||
<span className="text-muted-foreground">IPv6: <span className="font-mono text-sky-400">{s.ipv6Address}</span></span>
|
||||
)}
|
||||
{s.vrfNames?.map((v) => (
|
||||
<span key={v} className="text-muted-foreground">VRF: <span className="font-mono text-foreground">{v}</span></span>
|
||||
))}
|
||||
{s.comment && <span className="text-muted-foreground italic">{s.comment}</span>}
|
||||
{s.polledAt ? (
|
||||
<span className="text-muted-foreground/50 text-[11px]">
|
||||
Опрошен: {new Date(s.polledAt).toLocaleString("ru")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-500/70 text-[11px]">⚠ Ещё не опрашивался</span>
|
||||
)}
|
||||
</div>
|
||||
{isLive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs shrink-0"
|
||||
disabled={isPolling}
|
||||
onClick={(e) => { e.stopPropagation(); onPoll() }}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить сейчас"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Возможности RouterOS
|
||||
</p>
|
||||
<RosBadge os={s.os} />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{ver >= 715
|
||||
? "✓ Актуальная версия — все ключевые фичи доступны"
|
||||
: ver >= 710
|
||||
? "⚠ Рекомендуется обновление до 7.15+"
|
||||
: s.os !== "—"
|
||||
? "✗ Устаревшая версия — требуется обновление"
|
||||
: "Нет данных — нажмите «Опросить сейчас»"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-2">
|
||||
{ROS_FEATURES.map((f) => {
|
||||
const ok = ver >= f.minVer
|
||||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md border px-3 py-2.5 transition-colors",
|
||||
ok ? "border-emerald-500/25 bg-emerald-500/5" : "border-border/40 bg-background/40 opacity-60",
|
||||
)}
|
||||
>
|
||||
{ok
|
||||
? <CheckCircleIcon className="size-3.5 text-emerald-500 shrink-0 mt-0.5" />
|
||||
: <XCircleIcon className="size-3.5 text-muted-foreground/40 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<p className={cn("text-xs font-medium leading-tight truncate", ok ? "text-foreground" : "text-muted-foreground")}>
|
||||
{f.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight mt-0.5">
|
||||
{f.label} · {f.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerExpandedDetail }
|
||||
@@ -0,0 +1,452 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ServerExpandedDetail } from "@/components/data-grids/server-expanded-detail"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ChevronDownIcon,
|
||||
HomeIcon,
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
WifiIcon,
|
||||
MoreHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
PowerIcon,
|
||||
Trash2Icon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowUpDownIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"jump-host": "JumpHost",
|
||||
"exit-node": "Exit Node",
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const TYPE_STYLES: Record<ServerType, string> = {
|
||||
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
||||
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
||||
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
}
|
||||
|
||||
const CELL_PAD = "py-3"
|
||||
const CELL_PAD_FIRST = "pl-5 py-3"
|
||||
const CELL_PAD_LAST = "pr-4 py-3"
|
||||
|
||||
function rosVer(os: string): number {
|
||||
const m = os.match(/(\d+)\.(\d+)/)
|
||||
if (!m) return 0
|
||||
return parseInt(m[1], 10) * 100 + parseInt(m[2], 10)
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: ServerType }) {
|
||||
const icon =
|
||||
type === "jump-host" ? <ServerIcon className="size-3 mr-1" />
|
||||
: type === "exit-node" ? <NetworkIcon className="size-3 mr-1" />
|
||||
: <HomeIcon className="size-3 mr-1" />
|
||||
return (
|
||||
<span className={cn("inline-flex items-center text-xs font-medium border rounded px-2 py-0.5", TYPE_STYLES[type])}>
|
||||
{icon}{TYPE_LABELS[type]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RosBadge({ os }: { os: string }) {
|
||||
const v = rosVer(os)
|
||||
const cls = v >= 715
|
||||
? "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20"
|
||||
: v >= 710
|
||||
? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20"
|
||||
: "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/20"
|
||||
return <span className={cn("text-xs font-mono border rounded px-2 py-0.5", cls)}>{os}</span>
|
||||
}
|
||||
|
||||
function ServersTableHeader<TData>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: Column<TData, unknown>
|
||||
title: string
|
||||
className?: string
|
||||
}) {
|
||||
const sorted = column.getIsSorted()
|
||||
const canSort = column.getCanSort()
|
||||
|
||||
if (!canSort) {
|
||||
return (
|
||||
<span className={cn("text-xs font-medium text-muted-foreground", className)}>
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground",
|
||||
"hover:text-foreground transition-colors rounded-md -ml-1 px-1 py-0.5",
|
||||
className,
|
||||
)}
|
||||
onClick={column.getToggleSortingHandler()}
|
||||
>
|
||||
{title}
|
||||
{sorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3 text-foreground" />
|
||||
) : sorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3 text-foreground" />
|
||||
) : (
|
||||
<ArrowUpDownIcon className="size-3 opacity-35" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
server: Server
|
||||
isLive: boolean
|
||||
isPolling: boolean
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onPoll: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServerRowActions({
|
||||
server,
|
||||
isLive,
|
||||
isPolling,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPoll,
|
||||
onToggleStatus,
|
||||
}: ServerRowActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-8 shrink-0 border-border/60 bg-background/80 text-muted-foreground shadow-none",
|
||||
"opacity-0 transition-[opacity,background-color,color,border-color]",
|
||||
"group-hover/row:opacity-100 focus-visible:opacity-100",
|
||||
"data-popup-open:opacity-100 data-popup-open:bg-muted",
|
||||
"hover:bg-muted hover:text-foreground hover:border-border",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Действия: ${server.name}`}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" side="bottom" className="w-52">
|
||||
<DropdownMenuItem onClick={() => window.open(`https://${server.host}`, "_blank")}>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть WebFig
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(server)}>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
{isLive && (
|
||||
<DropdownMenuItem onClick={() => onPoll(server.id)} disabled={isPolling}>
|
||||
<RefreshCwIcon className={cn("size-4", isPolling && "animate-spin")} />
|
||||
{isPolling ? "Опрос…" : "Опросить"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{server.status === "offline" ? "Включить" : "Отключить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServersDataGridProps {
|
||||
servers: Server[]
|
||||
isLive: boolean
|
||||
pollingIds: Set<string>
|
||||
onPoll: (id: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onDelete: (id: string) => void
|
||||
onToggleStatus: (id: string) => void
|
||||
}
|
||||
|
||||
function ServersDataGrid({
|
||||
servers,
|
||||
isLive,
|
||||
pollingIds,
|
||||
onPoll,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: ServersDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Server>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<ServersTableHeader column={column} title="Имя / Хост" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
const expanded = row.getIsExpanded()
|
||||
return (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{s.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{s.host}</p>
|
||||
{s.ipv6Address && (
|
||||
<p className="text-[10px] font-mono text-sky-500/70 truncate max-w-[150px]" title={s.ipv6Address}>
|
||||
{s.ipv6Address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Имя / Хост",
|
||||
headerClassName: CELL_PAD_FIRST,
|
||||
cellClassName: CELL_PAD_FIRST,
|
||||
expandedContent: (row: Server) => (
|
||||
<ServerExpandedDetail
|
||||
server={row}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(row.id)}
|
||||
onPoll={() => onPoll(row.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => <TypeBadge type={row.original.type} />,
|
||||
meta: { headerTitle: "Тип", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
accessorKey: "model",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Модель" />,
|
||||
cell: ({ row }) => <span className="text-muted-foreground text-xs">{row.original.model}</span>,
|
||||
meta: { headerTitle: "Модель", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "os",
|
||||
accessorKey: "os",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="RouterOS" />,
|
||||
cell: ({ row }) => <RosBadge os={row.original.os} />,
|
||||
meta: { headerTitle: "RouterOS", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "site",
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Площадка" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Flag code={row.original.country} />
|
||||
<span className="font-medium text-sm">{row.original.site}</span>
|
||||
</div>
|
||||
),
|
||||
meta: { headerTitle: "Площадка", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "wan",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">WAN / LAN</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
if (s.type === "home-router" && s.wanUplinks?.length) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wanUplinks.map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<WifiIcon className="size-3 text-sky-400 shrink-0" />
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400">{w.name}</span>
|
||||
<span className="text-muted-foreground">{w.isp}</span>
|
||||
<span className="text-muted-foreground">↓{w.maxDl}↑{w.maxUl}</span>
|
||||
</div>
|
||||
))}
|
||||
{s.lanSubnet && (
|
||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">LAN {s.lanSubnet}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && (
|
||||
<div className="text-[11px] font-mono text-violet-500 dark:text-violet-400 flex items-center gap-1">
|
||||
<ShieldIcon className="size-3" />
|
||||
WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers
|
||||
</div>
|
||||
)}
|
||||
{s.rpkiEnabled && (
|
||||
<div className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">RPKI ✓</div>
|
||||
)}
|
||||
{!s.wireGuardIfaces?.length && !s.rpkiEnabled && (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerTitle: "WAN / LAN", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorKey: "latency",
|
||||
header: ({ column }) => (
|
||||
<ServersTableHeader column={column} title="Задержка" className="w-full justify-end" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<span className={cn(
|
||||
"font-mono text-sm block text-right tabular-nums",
|
||||
s.latency == null ? "text-muted-foreground" : s.latency > 60 ? "text-[var(--status-degraded-fg)]" : "",
|
||||
)}>
|
||||
{s.latency == null ? "—" : `${s.latency} мс`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Задержка",
|
||||
headerClassName: cn(CELL_PAD, "text-right"),
|
||||
cellClassName: cn(CELL_PAD, "text-right"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <ServersTableHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: CELL_PAD, cellClassName: CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original
|
||||
return (
|
||||
<ServerRowActions
|
||||
server={s}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(s.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onPoll={onPoll}
|
||||
onToggleStatus={onToggleStatus}
|
||||
/>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 56,
|
||||
meta: {
|
||||
headerClassName: CELL_PAD_LAST,
|
||||
cellClassName: CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: servers,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
title="Нет серверов"
|
||||
description="Добавьте первый MikroTik-сервер для мониторинга"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={servers.length}
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
tableLayout={{
|
||||
rowBorder: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
columnsResizable: false,
|
||||
}}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: "group/row",
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false} className="rounded-none border-0">
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServersDataGrid, rosVer, RosBadge, TypeBadge }
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import { SegmentedControl } from "@/components/form-kit"
|
||||
|
||||
interface DataPageToolbarProps<T extends string = string> {
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
segmented?: {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
}
|
||||
filters?: Filter[]
|
||||
onFiltersChange?: (filters: Filter[]) => void
|
||||
filterFields?: FilterFieldConfig[]
|
||||
countLabel?: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataPageToolbar<T extends string = string>({
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Поиск…",
|
||||
segmented,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
filterFields,
|
||||
countLabel,
|
||||
actions,
|
||||
className,
|
||||
}: DataPageToolbarProps<T>) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 px-5 py-3 border-b flex-wrap", className)}>
|
||||
{segmented && (
|
||||
<SegmentedControl
|
||||
value={segmented.value}
|
||||
onChange={segmented.onChange}
|
||||
options={segmented.options}
|
||||
/>
|
||||
)}
|
||||
{filterFields && filters && onFiltersChange && (
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={onFiltersChange}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{onSearchChange != null && (
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
className="h-6 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{countLabel && (
|
||||
<span className="text-sm text-muted-foreground ml-auto">{countLabel}</span>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataPageToolbar, type DataPageToolbarProps }
|
||||
+76
-37
@@ -1,8 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridPagination,
|
||||
DataGridTable,
|
||||
} from "@/components/reui/data-grid"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
@@ -15,6 +31,9 @@ interface DataTableProps<T extends { id: string }> {
|
||||
columns: Column<T>[]
|
||||
searchPlaceholder?: string
|
||||
searchKeys?: (keyof T)[]
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id: string }>({
|
||||
@@ -22,10 +41,14 @@ export function DataTable<T extends { id: string }>({
|
||||
columns,
|
||||
searchPlaceholder = "Поиск…",
|
||||
searchKeys = [],
|
||||
isLoading = false,
|
||||
emptyTitle = "Нет записей",
|
||||
emptyDescription,
|
||||
}: DataTableProps<T>) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search || searchKeys.length === 0) return data
|
||||
const s = search.toLowerCase()
|
||||
return data.filter((row) =>
|
||||
@@ -33,44 +56,60 @@ export function DataTable<T extends { id: string }>({
|
||||
)
|
||||
}, [data, search, searchKeys])
|
||||
|
||||
const columnDefs = useMemo<ColumnDef<T>[]>(
|
||||
() =>
|
||||
columns.map((col) => ({
|
||||
id: col.key,
|
||||
accessorKey: col.key,
|
||||
header: col.label,
|
||||
cell: ({ row }) => col.render(row.original),
|
||||
meta: { headerTitle: col.label },
|
||||
})),
|
||||
[columns],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns: columnDefs,
|
||||
state: { globalFilter },
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const displayCount = searchKeys.length > 0 ? filteredData.length : data.length
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b">
|
||||
<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={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
countLabel={`${displayCount} записей`}
|
||||
/>
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
emptyMessage={
|
||||
<EmptyState
|
||||
icon={<InboxIcon className="size-4" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="py-12"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-1">{filtered.length} записей</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="text-left font-medium px-5 py-3">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-5 py-3">
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
tableLayout={{ rowBorder: true, headerBackground: true }}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
<DataGridTable />
|
||||
</DataGridContainer>
|
||||
{filteredData.length > 0 && <DataGridPagination className="px-5 pb-3" />}
|
||||
</DataGrid>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<Empty className={cn("border-0 py-16", className)}>
|
||||
<EmptyHeader>
|
||||
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||
</EmptyHeader>
|
||||
{action && <EmptyContent>{action}</EmptyContent>}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
export { EmptyState, type EmptyStateProps }
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FileImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
onImport: (files: File[]) => void | Promise<void>
|
||||
}
|
||||
|
||||
function FileImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
accept = "*",
|
||||
multiple = false,
|
||||
onImport,
|
||||
}: FileImportDialogProps) {
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const [{ files, isDragging, errors }, actions] = useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
maxFiles: multiple ? 10 : 1,
|
||||
onError: (errs) => {
|
||||
errs.forEach((e) => toast.error(e))
|
||||
},
|
||||
})
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const rawFiles = files
|
||||
.map((f) => (f.file instanceof File ? f.file : null))
|
||||
.filter((f): f is File => f != null)
|
||||
if (rawFiles.length === 0) {
|
||||
toast.error("Выберите файл для импорта")
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
try {
|
||||
await onImport(rawFiles)
|
||||
actions.clearFiles()
|
||||
onOpenChange(false)
|
||||
toast.success("Импорт выполнен")
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Ошибка импорта")
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [actions, files, onImport, onOpenChange])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors",
|
||||
isDragging ? "border-primary bg-primary/5" : "border-border",
|
||||
)}
|
||||
onDragEnter={actions.handleDragEnter}
|
||||
onDragLeave={actions.handleDragLeave}
|
||||
onDragOver={actions.handleDragOver}
|
||||
onDrop={actions.handleDrop}
|
||||
>
|
||||
<UploadIcon className="size-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Перетащите файл сюда или выберите на диске
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={actions.openFileDialog}>
|
||||
Выбрать файл
|
||||
</Button>
|
||||
<input {...actions.getInputProps()} className="sr-only" />
|
||||
{files.length > 0 && (
|
||||
<ul className="w-full text-left text-sm">
|
||||
{files.map((f) => (
|
||||
<li key={f.id} className="truncate font-mono text-xs">
|
||||
{f.file instanceof File ? f.file.name : f.file.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="text-xs text-destructive">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" onClick={handleImport} disabled={importing || files.length === 0}>
|
||||
{importing ? "Импорт…" : "Импортировать"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export { FileImportDialog, type FileImportDialogProps }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
hint?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FormField({ label, hint, error, required, children }: FormFieldProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error}>
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</FieldLabel>
|
||||
{children}
|
||||
{hint && !error && <FieldDescription>{hint}</FieldDescription>}
|
||||
{error && <FieldDescription className="text-destructive">{error}</FieldDescription>}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormField, type FormFieldProps }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FormToggleProps {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function FormToggle({ checked, onChange, disabled, className }: FormToggleProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
className={cn(className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { FormToggle, type FormToggleProps }
|
||||
@@ -0,0 +1,4 @@
|
||||
export { FormField, type FormFieldProps } from "./form-field"
|
||||
export { FormToggle, type FormToggleProps } from "./form-toggle"
|
||||
export { SectionTitle, type SectionTitleProps } from "./section-title"
|
||||
export { SegmentedControl, type SegmentedControlProps } from "./segmented-control"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SectionTitleProps {
|
||||
children: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SectionTitle({ children, icon, className }: SectionTitleProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 py-0.5", className)}>
|
||||
{icon}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SectionTitle, type SectionTitleProps }
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
options: { value: T; label: string; count?: number }[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors",
|
||||
value === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{option.count != null && (
|
||||
<span className="text-xs tabular-nums opacity-60">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SegmentedControl, type SegmentedControlProps }
|
||||
@@ -13,16 +13,14 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
||||
import {
|
||||
formatCommitSubject,
|
||||
getReleaseCommitPreview,
|
||||
RELEASE_COMMIT_PREVIEW_LIMIT,
|
||||
} from "@/lib/release-commits"
|
||||
import type { ReleaseManifest } from "@/lib/release-manifest"
|
||||
import { hasSeenReleaseModal, markReleaseModalSeen } from "@/lib/release-modal-storage"
|
||||
|
||||
function formatCommitSubject(subject: string): string {
|
||||
return subject.replace(
|
||||
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*/i,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseNotesModal() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [manifest, setManifest] = React.useState<ReleaseManifest | null>(null)
|
||||
@@ -60,6 +58,7 @@ export function ReleaseNotesModal() {
|
||||
}
|
||||
|
||||
const versionLabel = formatAppVersionLabel(manifest?.version ?? getAppVersion())
|
||||
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -69,14 +68,14 @@ export function ReleaseNotesModal() {
|
||||
Что нового в {versionLabel}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
Краткая сводка изменений текущего релиза MikrotikManager.
|
||||
Последние {RELEASE_COMMIT_PREVIEW_LIMIT} коммитов текущей сборки MikrotikManager.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
{manifest?.commits.length ? (
|
||||
{commits.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{manifest.commits.map((commit) => (
|
||||
{commits.map((commit) => (
|
||||
<li
|
||||
key={commit.sha}
|
||||
className="rounded-lg border bg-card/80 p-3"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
[
|
||||
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||
"rounded-lg",
|
||||
"px-3",
|
||||
"py-2.5",
|
||||
"has-[>svg]:gap-x-2.5",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||
invert:
|
||||
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn(
|
||||
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
outline: "border-border bg-transparent dark:bg-input/32",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
info: "bg-info text-white",
|
||||
success: "bg-success text-white",
|
||||
warning: "bg-warning text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||
"warning-light":
|
||||
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||
"success-light":
|
||||
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||
"info-light":
|
||||
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||
"destructive-light":
|
||||
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||
"invert-light":
|
||||
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||
"focus-light":
|
||||
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||
"success-outline":
|
||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||
"info-outline":
|
||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||
"destructive-outline":
|
||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||
"invert-outline":
|
||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||
"focus-outline":
|
||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||
},
|
||||
size: {
|
||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
full: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
render,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "badge",
|
||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants, type BadgeProps }
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user