Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5b7f0ea6 | ||
|
|
b4d2d4bc20 | ||
|
|
c90a6ca3b8 | ||
|
|
bf4b9b5443 | ||
|
|
5ce56b5168 | ||
|
|
f1af1a85e5 | ||
|
|
414a419f18 | ||
|
|
6a286a1f66 | ||
|
|
bf3d50e1ff |
No files matched your search
@@ -11,7 +11,7 @@ const outputDir = process.env.RELEASE_OUTPUT_DIR
|
||||
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
|
||||
: join(repoRoot, ".ci", "release")
|
||||
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
|
||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
|
||||
|
||||
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
|
||||
const CONVENTIONAL_RE =
|
||||
@@ -54,6 +54,12 @@ function formatSemver(parts) {
|
||||
|
||||
function bumpVersion(version, bump) {
|
||||
const parts = parseSemver(version)
|
||||
if (bump === "major") {
|
||||
parts.major += 1
|
||||
parts.minor = 0
|
||||
parts.patch = 0
|
||||
return formatSemver(parts)
|
||||
}
|
||||
if (bump === "minor") {
|
||||
parts.minor += 1
|
||||
parts.patch = 0
|
||||
@@ -66,8 +72,8 @@ function bumpVersion(version, bump) {
|
||||
function parseCommit(subject, body = "") {
|
||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||
const breaking =
|
||||
/BREAKING CHANGE/i.test(body) ||
|
||||
(match?.[1] && subject.includes(`${match[1]}!:`))
|
||||
/BREAKING[ -]CHANGE:/i.test(body) ||
|
||||
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
||||
|
||||
if (!match) {
|
||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||
@@ -78,7 +84,9 @@ function parseCommit(subject, body = "") {
|
||||
const description = match[3].trim()
|
||||
let bump = "patch"
|
||||
|
||||
if (breaking || MINOR_TYPES.has(type)) {
|
||||
if (breaking) {
|
||||
bump = "major"
|
||||
} else if (MINOR_TYPES.has(type)) {
|
||||
bump = "minor"
|
||||
} else if (PATCH_TYPES.has(type)) {
|
||||
bump = "patch"
|
||||
@@ -251,7 +259,9 @@ function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||
const bump = commits.some((commit) => commit.bump === "major")
|
||||
? "major"
|
||||
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||
const version = bumpVersion(baseFromTag, bump)
|
||||
const tag = `v${version}`
|
||||
const publishedAt = new Date().toISOString()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import test from "node:test"
|
||||
|
||||
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
|
||||
|
||||
const cases = [
|
||||
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
|
||||
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
|
||||
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
|
||||
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
|
||||
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
|
||||
]
|
||||
|
||||
for (const scenario of cases) test(scenario.name, () => {
|
||||
const parent = resolve(tmpdir())
|
||||
const root = mkdtempSync(join(parent, "mm-release-"))
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
|
||||
try {
|
||||
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
|
||||
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
|
||||
git("init", "--initial-branch=main")
|
||||
git("config", "user.name", "Release Test")
|
||||
git("config", "user.email", "release@example.invalid")
|
||||
git("config", "core.hooksPath", ".git/hooks")
|
||||
writeFileSync(join(root, "fixture"), "baseline")
|
||||
git("add", "fixture")
|
||||
git("commit", "-m", "chore(test): создать исходную базу")
|
||||
git("tag", `v${scenario.base ?? "1.47.6"}`)
|
||||
for (const [index, [subject, body]] of scenario.commits.entries()) {
|
||||
writeFileSync(join(root, "fixture"), `change ${index}`)
|
||||
git("add", "fixture")
|
||||
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
|
||||
}
|
||||
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
|
||||
cwd: root, stdio: "pipe",
|
||||
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
|
||||
})
|
||||
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
|
||||
const output = readFileSync(join(root, "outputs"), "utf8")
|
||||
assert.equal(manifest.version, scenario.version)
|
||||
assert.equal(manifest.tag, `v${scenario.version}`)
|
||||
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
|
||||
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
|
||||
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
|
||||
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(root)), parent)
|
||||
assert.ok(basename(root).startsWith("mm-release-"))
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,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")
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install *)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/dashboard)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000__TRACKED_VAR__)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001__TRACKED_VAR__)",
|
||||
"PowerShell(netstat -ano | Select-String \":300\" | Where-Object { $_ -match \"LISTENING\" })",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
||||
"WebFetch(domain:git.shts.su)",
|
||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
||||
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
||||
"mcp__Claude_in_Chrome__tabs_context_mcp",
|
||||
"Bash(npx eslint *)",
|
||||
"Bash(grep -E \"^C:\\\\\\\\\\\\\\\\| [0-9]+:[0-9]+ error\")",
|
||||
"Bash(grep -E \"\\(\\\\\\\\\\\\\\\\.*\\\\.tsx$\\)|\\(error\\)\")",
|
||||
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:60]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
|
||||
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:80]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
|
||||
"Bash(npx next *)",
|
||||
"Bash(dir \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\")",
|
||||
"Bash(Get-ChildItem \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\" -Directory)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(Sort-Object Name)",
|
||||
"Bash(awk 'NR>=508 && NR<=730' \"app/\\(main\\)/servers/page.tsx\")",
|
||||
"Bash(grep -n \"Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/filters/page.tsx\")",
|
||||
"Bash(grep -n \"\\\\.map\\\\|Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/ospf/page.tsx\")",
|
||||
"Bash(grep -v \"//.*console\\\\|ErrorRecord\\\\|console\\\\.error.*err\\\\|any>\\\\|: any\\\\|as any\\\\|any\\\\[\\\\]\\\\|Record<.*any\\\\|Promise<any\")",
|
||||
"Bash(curl -s http://localhost:8000/api/servers)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/servers/1/poll)",
|
||||
"Bash(curl -s http://localhost:8000/api/servers/1)",
|
||||
"Bash(curl -s http://localhost:8000/api/servers/1/snapshots)",
|
||||
"Bash(curl -s -X DELETE http://localhost:8000/api/servers/1 -w \"\\\\nHTTP %{http_code}\")",
|
||||
"Bash(curl -s http://localhost:8000/api/servers/999)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/servers -H \"Content-Type: application/json\" -d \"{}\")",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/servers/test-connection -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"apiPath\":\"/rest\",\"username\":\"admin\",\"password\":\"\"}')",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\",\"asn\":\"\",\"comment\":\"Тестовый роутер\",\"enabled\":true}')",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\"}')",
|
||||
"Bash(node -e \"const d=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const a=JSON.parse\\(d\\); a.forEach\\(s=>console.log\\(s.id, s.host, s.name\\)\\)\")",
|
||||
"Bash(curl -s http://localhost:8000/api/bgp/sessions)",
|
||||
"Bash(curl -s \"http://localhost:8000/api/bgp/sessions/raw/3\")",
|
||||
"Bash(node -e \"0\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/neighbor\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/instance\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/area\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/interface-template\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route?.type=ospf\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/neighbor\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/instance\")",
|
||||
"Bash(curl -s http://localhost:8000/api/ospf/all)",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/bfd/session\")",
|
||||
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/bfd/session\")",
|
||||
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")",
|
||||
"Bash(xargs grep *)",
|
||||
"Bash(npx shadcn@latest add popover --yes)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H6","location":"components/debug-runtime-probe.tsx","message":"cursor-ref-count-on-mount","data":{"pathname":"/bgp","count":80},"timestamp":1777738439411}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H5","location":"components/debug-runtime-probe.tsx","message":"route-change","data":{"pathname":"/bgp","search":""},"timestamp":1777738439411}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439416}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738439428}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738439603}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738439605}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738439621}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508126}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508134}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508145}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508204}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508205}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508228}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508553}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"mock","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508560}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508571}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"hydrated-from-storage","data":{"mode":"live","backendUrl":"http://localhost:8000"},"timestamp":1777738508572}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H7","location":"lib/data-source.tsx","message":"provider-mounted","data":{"mode":"live","backendUrl":"http://localhost:8000","storedMode":"live","storedBackend":"http://localhost:8000"},"timestamp":1777738508662}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-start","data":{"url":"http://localhost:8000"},"timestamp":1777738508663}
|
||||
{"sessionId":"00844d","runId":"initial","hypothesisId":"H4","location":"lib/data-source.tsx","message":"check-backend-result","data":{"url":"http://localhost:8000","ok":true,"status":200},"timestamp":1777738508676}
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
description: "Generate Commit Message: русский subject, fix vs feat, Conventional Commits"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git commit message (Generate Commit Message)
|
||||
|
||||
При **Generate Commit Message**, автогенерации subject и предложении `git commit`:
|
||||
|
||||
## Обязательно
|
||||
|
||||
- Subject и body — **только на русском** (после `:` в conventional commit).
|
||||
- Локальный hook `.githooks/commit-msg` **отклоняет** коммиты без кириллицы в subject.
|
||||
- Префикс и scope — английский: `fix(ui):`, `chore(rules):`.
|
||||
|
||||
## Тип: сначала не `feat`
|
||||
|
||||
- **`fix`** — баг, регрессия, правка вёрстки/модалки, некорректное поведение, CI/CD, мелкий дефект.
|
||||
- **`chore` / `docs` / `ci` / `build`** — правила Cursor, README, workflow, зависимости, рефакторинг без новой UX-фичи.
|
||||
- **`feat`** — только **новая** пользовательская возможность (страница, API, сценарий), которой не было.
|
||||
|
||||
Если сомневаешься между `feat` и `fix` — выбирай **`fix`** или **`chore`**, не `feat`.
|
||||
|
||||
## Примеры
|
||||
|
||||
- `fix(ui): выровнять модалку сводки релиза`
|
||||
- `fix(ci): исправить сборку release manifest`
|
||||
- `chore(rules): усилить правила русских commit message`
|
||||
- `feat(releases): добавить страницу релизов`
|
||||
|
||||
## Запрещено
|
||||
|
||||
- `feat: update layout`, `fix: add modal`, `chore: update rules` — английский subject.
|
||||
- `feat` для правки UI, правил, документации и багфиксов.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
description: "Быстрый запуск локального frontend и backend"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Локальный запуск проекта
|
||||
|
||||
Для запуска всего проекта в dev-режиме поднимать два процесса:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
npm --prefix backend run dev
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend: `http://localhost:8000`
|
||||
- Health check backend: `http://localhost:8000/health`
|
||||
|
||||
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
description: "Fastify 5 + Zod + Drizzle + SQLite — модульная архитектура, слои, валидация, ошибки (ответы на русском)"
|
||||
globs: "**/{features,shared,lib,plugins}/**/*.ts,**/app.ts,**/server.ts"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Backend: Fastify 5 + TypeScript (ESM) + Zod + Drizzle + SQLite
|
||||
|
||||
**Язык:** все ответы пользователю — **только на русском**.
|
||||
|
||||
**Стек:** TypeScript (`"type": "module"`), **Fastify 5.x**, **Zod** + `@fastify/type-provider-zod`, **SQLite** (`better-sqlite3`) + **Drizzle ORM**, `@fastify/cors`, `pino-pretty`, `undici`, `dotenv`.
|
||||
|
||||
**Цель:** масштабируемый модульный backend, предсказуемый DX, строгое разделение ответственности.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура (критично)
|
||||
|
||||
- **Feature-based модули**, не монолитные файлы. Каждая фича — изолированная папка.
|
||||
|
||||
Структура фичи:
|
||||
|
||||
```text
|
||||
/features/<feature-name>/
|
||||
route.ts — регистрация маршрутов Fastify
|
||||
controller.ts — разбор запроса/ответа, вызов сервиса
|
||||
service.ts — бизнес-логика (переиспользуемая)
|
||||
schema.ts — Zod-схемы (params, query, body, response)
|
||||
repository.ts — только Drizzle-запросы к БД
|
||||
types.ts — опционально
|
||||
```
|
||||
|
||||
**Запреты:** не смешивать слои; **нет** бизнес-логики в `route`; **нет** SQL/Drizzle в `controller`; сервисы не должны дублировать обязанности репозитория.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fastify
|
||||
|
||||
- Регистрация фич через **плагины** и инкапсулированные модули.
|
||||
- Везде **async/await**; декорации инстанса Fastify — только при реальной необходимости.
|
||||
|
||||
---
|
||||
|
||||
## 3. Валидация (Zod обязательна)
|
||||
|
||||
- **Все входы** — через Zod; `@fastify/type-provider-zod` для типобезопасности.
|
||||
- Явно задавать схемы: **params**, **query**, **body**, **response** (где уместно).
|
||||
- Нет «сырого» входа; схемы **переиспользовать** между слоями (импорт из `schema.ts`).
|
||||
|
||||
---
|
||||
|
||||
## 4. База данных (Drizzle)
|
||||
|
||||
- Любой доступ к БД — **только** из `repository.ts` / слоя репозитория.
|
||||
- Типизированные запросы Drizzle; **без** raw SQL, если нет веской причины.
|
||||
- Запросы — простые, композируемые; логику БД не выносить наружу репозитория.
|
||||
- Учитывать возможную смену БД: избегать sqlite-специфичных костылей вне репозитория/миграций.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ошибки
|
||||
|
||||
- Единый формат ошибок API (код/сообщение для клиента).
|
||||
- Обрабатывать: ошибки валидации, БД, внешних HTTP (`undici`).
|
||||
- **Не** отдавать клиенту внутренние детали (стейки, SQL, секреты).
|
||||
|
||||
---
|
||||
|
||||
## 6. Логирование
|
||||
|
||||
- **Pino**; в dev — **pino-pretty**.
|
||||
- Логировать ошибки и значимые действия; не засорять лог шумом.
|
||||
|
||||
---
|
||||
|
||||
## 7. Внешние HTTP (undici)
|
||||
|
||||
- Вызовы только из **service** (или выделенного клиента, вызываемого из сервиса).
|
||||
- Таймауты, обработка ошибок сети/статусов, без утечки сырого ответа наружу.
|
||||
|
||||
---
|
||||
|
||||
## 8. Конфигурация окружения
|
||||
|
||||
- `dotenv` для локальной разработки; **валидация env** (предпочтительно через Zod-схему).
|
||||
|
||||
---
|
||||
|
||||
## 9. Организация кода
|
||||
|
||||
- Небольшие файлы, одна ответственность на файл.
|
||||
- Композиция вместо копипаста; общее — в `/shared/` и `/lib/`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Масштабируемость
|
||||
|
||||
- Расширяемые границы модулей, слабая связанность, явные контракты между слоями.
|
||||
|
||||
---
|
||||
|
||||
## 11. Процесс изменений (обязательно)
|
||||
|
||||
Для каждого изменения в ответе указать: что сделано; зачем (архитектура + практики); затронутые файлы; минимальный полный дифф без лишнего рефакторинга.
|
||||
|
||||
---
|
||||
|
||||
## 12. Формат ответа агента
|
||||
|
||||
1. Что изменено
|
||||
2. Почему (архитектура + практики)
|
||||
3. Структура файлов (если новая фича)
|
||||
4. Код (несколько файлов при необходимости)
|
||||
5. Замечания / улучшения
|
||||
|
||||
---
|
||||
|
||||
## 13. Антипаттерны (строго запрещено)
|
||||
|
||||
- Толстые route handlers
|
||||
- Смешение БД и бизнес-логики в одном месте
|
||||
- Пропуск валидации входа
|
||||
- Глобальное мутабельное состояние
|
||||
- Дублирование запросов к БД
|
||||
- Хардкод секретов и конфигурации (всё через env/конфиг)
|
||||
|
||||
---
|
||||
|
||||
## Роль агента
|
||||
|
||||
Вести себя как **senior backend engineer**: чистый модульный код, строгое разделение слоёв, долгосрочная поддерживаемость и предсказуемый процесс разработки.
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль ANALYZER — анализ кодовой базы перед реализацией, без кода"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: ANALYZER (анализатор)
|
||||
|
||||
Ты выполняешь **только фазу анализа** перед любыми изменениями кода.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Искать по **всему** репозиторию релевантные места.
|
||||
- Зафиксировать:
|
||||
- похожие компоненты и дубликаты по смыслу;
|
||||
- существующие UI-паттерны, **особенно в зоне `/servers`** (`app/(main)/servers` и связанные компоненты);
|
||||
- общие хуки, утилиты, UI-обёртки;
|
||||
- стратегии загрузки данных (RSC, route handlers, server actions, клиентский fetch — что уже принято в проекте).
|
||||
- Явно отметить **риски дублирования** и конфликты с текущими абстракциями.
|
||||
|
||||
## Запреты
|
||||
|
||||
- **Нельзя** писать реализацию (патчи, готовый production-код, большие вставки «как сделать»). Допустимы короткие псевдокод-намёки уровня «расширить X», без полной реализации.
|
||||
- **Нельзя** предлагать «с нуля», если в кодовой базе уже есть переиспользуемое решение — приоритет **reuse**.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Релевантные файлы** (пути).
|
||||
2. **Существующие паттерны** (кратко, с привязкой к файлам).
|
||||
3. **Что переиспользовать** (компоненты, хуки, утилиты).
|
||||
4. **Риски и конфликты** (дубли, расхождение с `/servers`, ломкие зависимости).
|
||||
|
||||
## Цель фазы
|
||||
|
||||
Дать Implementer’у однозначный вход: **что трогать, что не плодить, откуда копировать поведение**.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Глобальные правила пайплайна (Next 16.2.4, Turbopack, shadcn) — подключать с ролями"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Глобальные правила (все агенты пайплайна)
|
||||
|
||||
**Язык:** отвечать пользователю **только на русском**.
|
||||
|
||||
## Стек и документация
|
||||
|
||||
- Next.js **16.2.4** (App Router, **Turbopack**), React, **shadcn/ui**.
|
||||
- Сверять решения с **официальной** документацией Next.js и shadcn для версий проекта. Не придумывать API и недокументированные паттерны.
|
||||
|
||||
## Встроенные возможности Next.js (предпочтительно)
|
||||
|
||||
- App Router, Server Components, Server Actions, Route Handlers — вместо обходных кастомных путей, если задача покрывается фреймворком.
|
||||
|
||||
## Эталон UI
|
||||
|
||||
- **`app/(main)/servers`** — главный эталон layout, сетки, типографики, композиции и взаимодействий. Не вводить новый UI-паттерн, если на `/servers` уже есть эквивалент.
|
||||
|
||||
## Качество и совместимость
|
||||
|
||||
- Устранять: ошибки сборки, TypeScript, гидратации, предупреждения о плохих практиках; учитывать **Turbopack** в dev.
|
||||
|
||||
## Связь с проектом
|
||||
|
||||
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
|
||||
- Сообщения коммитов: **`commit-messages-ru.mdc`** (язык и `fix` vs `feat`), semver bump — **`release-versioning.mdc`**.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль IMPLEMENTER — реализация по результатам анализа, /servers, shadcn"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: IMPLEMENTER (реализатор)
|
||||
|
||||
Ты пишешь **production-ready** изменения **на основе** отчёта Analyzer (и уточнённой задачи пользователя).
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Внедрять фичу **минимально**, но **полностью**: все затронутые файлы (типы, UI, API, actions/handlers) согласованы.
|
||||
- **Переиспользовать** существующие абстракции из отчёта анализа и кодовой базы.
|
||||
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
|
||||
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
|
||||
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
|
||||
- При завершении задачи предлагать subject по **`commit-messages-ru.mdc`**: правки UI/багов → `fix`, инфраструктура/правила → `chore`, новая фича → `feat`; описание **на русском**.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Что изменено** (файлы и суть).
|
||||
2. **Почему так** — связка Next.js + shadcn/ui + эталон **`/servers`** + принятые в репозитории паттерны.
|
||||
3. **Код** — диффы или полные фрагменты по всем затронутым файлам.
|
||||
4. По возможности: **Server vs Client** для новых/изменённых границ компонентов.
|
||||
|
||||
## Запреты
|
||||
|
||||
- Не оставлять «полфичи» (забытые типы, сломанные импорты, несогласованный API).
|
||||
- Не копипастить большие куски без привязки к существующим компонентам, если анализ уже указал переиспользование.
|
||||
|
||||
## Согласование с проектом
|
||||
|
||||
- Если активно правило **`next-shadcn-production.mdc`**, выдерживай его структуру ответа (шаги 0–6) и требования к проверкам.
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Оркестратор — последовательность Analyzer→Implementer→Reviewer→UI Guardian"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Оркестратор пайплайна (multi-agent)
|
||||
|
||||
Используй это правило, когда нужно прогнать задачу через **все роли по очереди**. Подключай вместе с **`ma-global.mdc`** и при необходимости **`next-shadcn-production.mdc`**.
|
||||
|
||||
## Язык
|
||||
|
||||
- На каждой фазе ответы пользователю — **на русском**.
|
||||
|
||||
## Порядок фаз (не нарушать)
|
||||
|
||||
1. **Analyzer** — правило **`ma-analyzer.mdc`**: только анализ, без реализации.
|
||||
2. **Implementer** — правило **`ma-implementer.mdc`**: код по результатам фазы 1.
|
||||
3. **Reviewer** — правило **`ma-reviewer.mdc`**: строгое ревью результата фазы 2.
|
||||
4. **UI Guardian** — правило **`ma-ui-guardian.mdc`**: только сверка с **`/servers`**.
|
||||
|
||||
## Как исполнять в одном чате
|
||||
|
||||
- Выполни **фазу 1** целиком и остановись на выходном формате Analyzer.
|
||||
- Затем **фаза 2** с явной ссылкой: «реализуй согласно отчёту Analyzer выше».
|
||||
- Затем **фаза 3** по артефакту фазы 2.
|
||||
- Затем **фаза 4** по UI-части изменений.
|
||||
|
||||
Если пользователь просит «только одну фазу» — выполни только её и напомни номер следующей.
|
||||
|
||||
## Промпт-шаблон для пользователя (скопировать в чат)
|
||||
|
||||
```text
|
||||
Запусти multi-agent пайплайн для задачи ниже.
|
||||
Фазы строго по порядку: Analyzer → Implementer → Reviewer → UI Guardian.
|
||||
Язык ответов — русский. Стек: Next.js 16.2.4 App Router + Turbopack, shadcn/ui.
|
||||
Эталон UI: app/(main)/servers.
|
||||
Следуй правилам ma-global и соответствующим ma-* ролям.
|
||||
|
||||
Задача:
|
||||
<опишите фичу или баг>
|
||||
```
|
||||
|
||||
## Файлы ролей в `.cursor/rules/`
|
||||
|
||||
| Роль | Файл |
|
||||
|------------|-------------------------|
|
||||
| Глобальные | `ma-global.mdc` |
|
||||
| Analyzer | `ma-analyzer.mdc` |
|
||||
| Implementer| `ma-implementer.mdc` |
|
||||
| Reviewer | `ma-reviewer.mdc` |
|
||||
| UI Guardian| `ma-ui-guardian.mdc` |
|
||||
| Оркестратор| `ma-orchestrator.mdc` |
|
||||
|
||||
## Ожидаемый результат пайплайна
|
||||
|
||||
- Согласованность с кодовой базой и **максимальный reuse**.
|
||||
- UI, выровненный под **`/servers`**.
|
||||
- Отсутствие известных блокеров по Next.js, гидратации, TS и заметных anti-patterns после фазы Reviewer.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль REVIEWER — строгая проверка Next/shadcn/a11y/гидратации/Turbopack"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: REVIEWER (ревьюер)
|
||||
|
||||
Ты **валидируешь** предложенные или уже внесённые изменения. Режим: **строгий**.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Что проверять
|
||||
|
||||
- **Next.js:** App Router, границы RSC/Client, данные ближе к серверу, кэширование/revalidate где уместно, корректные route handlers и server actions.
|
||||
- **shadcn/ui:** композиция, токены/классы проекта, отсутствие «левого» самописного UI там, где хватает shadcn.
|
||||
- **Производительность:** лишние клиентские деревья, waterfall fetch, чрезмерные `'use client'` границы.
|
||||
- **a11y:** семантика, фокус, aria, клавиатура (в разумных пределах контекста правки).
|
||||
- **Гидратация:** несовпадение разметки/случайные значения на сервере и клиенте, неправильное использование браузерных API в RSC.
|
||||
- **Turbopack:** не предлагать паттерны, ломающие dev-сборку Next 16 + Turbopack; учитывать типичные предупреждения toolchain.
|
||||
|
||||
## Обязательные находки
|
||||
|
||||
- Ошибочное **смешение Server/Client** (хуки в RSC, серверные модули в клиенте без обёртки и т.д.).
|
||||
- **Over-fetching** и дублирующая логика запросов.
|
||||
- **Дублирование** логики/UI там, где уже есть общий модуль или паттерн `/servers`.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
Таблица или списки:
|
||||
|
||||
1. **Проблема** (кратко).
|
||||
2. **Серьёзность:** блокер / высокая / средняя / низкая.
|
||||
3. **Рекомендация по исправлению** (конкретные шаги или патч-уровень).
|
||||
|
||||
Если дефектов нет — явно напиши **«Блокирующих замечаний нет»** и перечисли **что именно** было проверено.
|
||||
|
||||
## Запреты
|
||||
|
||||
- Нельзя «мягко пропускать» очевидные проблемы ради скорости.
|
||||
- Нельзя ограничиваться общими фразами — каждая **рекомендация должна быть actionable**.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
description: "[Multi-agent] Роль UI GUARDIAN — сверка UI только с эталоном /servers"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Агент: UI GUARDIAN (хранитель UI)
|
||||
|
||||
Ты отвечаешь **только** за **визуальную и UX-согласованность** с эталоном **`/servers`**.
|
||||
|
||||
## Язык
|
||||
|
||||
- Все ответы пользователю — **на русском**.
|
||||
|
||||
## Эталон
|
||||
|
||||
- Каталог **`app/(main)/servers`** и связанные компоненты страницы — **единственный** эталон для:
|
||||
- структуры layout (области страницы, карточки, секции);
|
||||
- сетки и отступов;
|
||||
- типографики (заголовки, подписи, плотность текста);
|
||||
- композиции shadcn-компонентов;
|
||||
- паттернов взаимодействия (кнопки, таблицы, диалоги, формы — как на `/servers`).
|
||||
|
||||
## Обязанности
|
||||
|
||||
- Сравнить **каждое** спорное UI-решение в изменениях с тем, как сделано на `/servers`.
|
||||
- Требовать **переиспользование** существующих компонентов/обёрток из этой зоны, если они покрывают задачу.
|
||||
|
||||
## Правила
|
||||
|
||||
- **Нельзя** принимать UI, который **системно расходится** с `/servers` без явной технической необходимости (которую нужно назвать).
|
||||
- **Нельзя** предлагать «новый стиль» ради вариации — только выравнивание с эталоном или расширение существующих примитивов.
|
||||
|
||||
## Формат вывода (строго)
|
||||
|
||||
1. **Несоответствия** эталону (конкретно: что именно отличается).
|
||||
2. **Обязательные правки** (что поменять).
|
||||
3. **Что переиспользовать** с `/servers`: файлы/компоненты и паттерн.
|
||||
|
||||
Если расхождений нет — явно: **«Расхождений с эталоном /servers не выявлено»** и кратко перечисли проверенные аспекты.
|
||||
|
||||
## Граница ответственности
|
||||
|
||||
- Не дублируй полный код-ревью Implementer/Reviewer: фокус только на **консистентности с /servers** и **reuse** UI.
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
description: Next.js App Router + shadcn/ui — эталон /servers, поиск по репозиторию, формат ответа и проверки
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Next.js 16 (App Router, Turbopack) + shadcn/ui — правила проекта
|
||||
|
||||
Стек ориентира: **Next.js 16.2.4** (App Router, Turbopack), **React**, **shadcn/ui**. При расхождении с документацией — верифицировать по официальным источникам для вашей версии.
|
||||
|
||||
**Язык:** все ответы пользователю — **только на русском**.
|
||||
|
||||
**Git / Generate Commit Message:** subject и body — **на русском**; `feat` только для новой UX-фичи, иначе `fix`/`chore`. См. **`commit-messages-ru.mdc`** и **`release-versioning.mdc`**.
|
||||
|
||||
## 1. Документация в первую очередь
|
||||
|
||||
- Решения сверять с **официальной** документацией Next.js и shadcn/ui (актуальные версии проекта).
|
||||
- Не выдумывать API и «недокументированные» паттерны; предпочитать стабильные, описанные в доках решения.
|
||||
|
||||
## 2. Эталон UI/UX: страница `/servers` (критично)
|
||||
|
||||
- **`app/(main)/servers`** (и связанные компоненты) — **главный эталон** дизайна и поведения.
|
||||
- Выравнивать: layout, отступы, сетку, типографику, структуру компонентов, паттерны взаимодействия.
|
||||
- Переиспользовать оттуда же компоненты и паттерны; **не вводить новый UI-паттерн**, если эквивалент уже есть на `/servers`.
|
||||
|
||||
## 3. Большой репозиторий (критично)
|
||||
|
||||
- **Перед изменениями** искать по проекту: похожие компоненты, существующие паттерны, общие утилиты, хуки, UI-абстракции.
|
||||
- **Не дублировать:** если похожее решение уже есть — расширять или переиспользовать, а не писать с нуля.
|
||||
|
||||
## 4. Согласованность между файлами
|
||||
|
||||
При правке фичи проверять и при необходимости обновлять **все** связанное: компоненты, хуки, route handlers / server actions, типы, стили/обёртки UI — не только открытый файл.
|
||||
|
||||
## 5. Зависимости и влияние
|
||||
|
||||
Перед реализацией: от чего зависит целевой файл, где используется, какие побочные эффекты; избегать ломающих изменений без явной необходимости.
|
||||
|
||||
## 6. Нативные возможности фреймворка
|
||||
|
||||
- Next.js: App Router, Server Components, Server Actions, Route Handlers — по умолчанию предпочтительнее кастомных обходных путей.
|
||||
- UI: компоненты **shadcn/ui**; не плодить самописное, если стандартное решение покрывает задачу.
|
||||
|
||||
## 7. Практики качества
|
||||
|
||||
- Рекомендации Next.js: загрузка данных, рендеринг, производительность.
|
||||
- Минимизировать `'use client'` там, где достаточно серверных компонентов.
|
||||
- Композиция shadcn/ui и доступность (a11y).
|
||||
|
||||
## 8. Валидация каждого изменения (обязательно)
|
||||
|
||||
В ответе явно указать:
|
||||
|
||||
- **Почему** это согласуется с Next.js, shadcn/ui и эталоном **`/servers`**.
|
||||
- **Server vs Client Component** и обоснование.
|
||||
- Что **переиспользовано** из проекта (паттерны/компоненты).
|
||||
- Какие **файлы проанализированы**.
|
||||
|
||||
## 9. Turbopack и консоль разработки
|
||||
|
||||
После правок: проверить сборку/типы (по возможности запустить проверки проекта), ошибки гидратации, предупреждения; критичное исправить; не игнорировать предупреждения о плохих практиках.
|
||||
|
||||
## 10. Правки кода
|
||||
|
||||
- Минимальные, но **полные** (все зависимые места обновлены).
|
||||
- Не рефакторить несвязанную логику.
|
||||
|
||||
## 11. Формат ответа (обязательная структура)
|
||||
|
||||
1. **Шаг 0:** список проанализированных файлов.
|
||||
2. **Шаг 1:** что изменено.
|
||||
3. **Шаг 2:** почему (Next.js + shadcn/ui + `/servers` + паттерны кодовой базы).
|
||||
4. **Шаг 3:** анализ влияния (что ещё затронуто).
|
||||
5. **Шаг 4:** код (диффы или несколько файлов).
|
||||
6. **Шаг 5:** проверка dev/консоли (ошибки, предупреждения, исправления).
|
||||
7. **Шаг 6:** опциональные улучшения.
|
||||
|
||||
## 12. Дублирование и антипаттерны
|
||||
|
||||
Явно указывать, если найдено дублирование или антипаттерн; ссылаться на существующую реализацию в проекте; предлагать переиспользование или рефакторинг вместо копипаста.
|
||||
|
||||
## Цель
|
||||
|
||||
Вести себя как **senior** в production-кодовой базе: консистентность, переиспользование, выравнивание с `/servers`, безопасные масштабируемые изменения по best practices Next.js и shadcn/ui, готовый к продакшену код без ошибок сборки/рантайма по возможности.
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
description: "Conventional Commits, semver релизов MikrotikManager и русские git commit message (Generate Commit Message)"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Версионирование релизов
|
||||
|
||||
## Generate Commit Message (критично)
|
||||
|
||||
Для **Generate Commit Message** в Cursor, предложения subject в чате, `git commit` и любой автогенерации сообщения коммита:
|
||||
|
||||
- Формат: `тип(scope): описание на русском` — префикс Conventional Commits и scope **на английском**, текст после `:` **только на русском**.
|
||||
- Body и footer (если нужны) — **только на русском**.
|
||||
- В описании после `:` не использовать английские слова: `add`, `update`, `implement`, `rename` и т.п.
|
||||
- **`feat` не по умолчанию.** Сначала проверь `fix` / `chore` / `ci` / `docs`; `feat` — только новая пользовательская возможность.
|
||||
- Примеры: `fix(ui): выровнять модалку релиза`; `chore(rules): усилить правила commit message`; `feat(releases): добавить страницу релизов`.
|
||||
- Детальный чеклист типов — **`commit-messages-ru.mdc`**.
|
||||
|
||||
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
|
||||
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
|
||||
- **Язык коммитов:** subject и body — **только на русском**; префикс Conventional Commits (`feat`, `fix`, `chore` и т.д.) и scope — на английском.
|
||||
|
||||
## Patch (`1.0.x`)
|
||||
|
||||
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
|
||||
|
||||
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов, **на русском**.
|
||||
- Scope по желанию: `fix(servers):`, `chore(ci):`.
|
||||
|
||||
## Minor (`1.x.0`)
|
||||
|
||||
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
|
||||
|
||||
## Примеры
|
||||
|
||||
- `fix(servers): исправить опрос API после таймаута` — patch
|
||||
- `feat(filters): добавить синхронизацию BGP in` — minor
|
||||
- `chore(ci): добавить job prepare-release` — хорошо
|
||||
- `feat: add release modal` — плохо (subject не на русском)
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
WIP, «update», subject на английском, смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
|
||||
|
||||
Подробности — раздел CI/CD в `README.md`.
|
||||
@@ -0,0 +1,5 @@
|
||||
deploy/netflow-agent/*.sh text eol=lf
|
||||
deploy/netflow-agent/*.py text eol=lf
|
||||
# Migration hashes authenticate exact reviewed bytes, including existing CRLF files.
|
||||
# Preserve those bytes on every checkout instead of applying core.autocrlf.
|
||||
backend/drizzle/*.sql -text
|
||||
+163
-11
@@ -7,8 +7,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.shts.su
|
||||
GITEA_SERVER_URL: https://git.shts.su
|
||||
REGISTRY: git.shx.one
|
||||
GITEA_SERVER_URL: https://git.shx.one
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Test release version calculation
|
||||
run: node --test .ci/scripts/compute-release.test.mjs
|
||||
|
||||
- name: Compute release metadata
|
||||
id: release
|
||||
shell: bash
|
||||
@@ -38,8 +41,85 @@ jobs:
|
||||
node .ci/scripts/compute-release.mjs
|
||||
test -f .ci/release/release-manifest.json
|
||||
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Start PostgreSQL 18
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
docker rm -f "$NAME" 2>/dev/null || true
|
||||
# Docker-backed Gitea runners execute this step in a separate container.
|
||||
# Join its network; a published host loopback port is not reachable here.
|
||||
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
|
||||
if [ -n "$NETWORKS" ]; then
|
||||
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
|
||||
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
|
||||
PG_HOST="$NAME"
|
||||
PG_PORT=5432
|
||||
else
|
||||
# Host-based runners can use a Docker-assigned ephemeral host port.
|
||||
NETWORK_ARGS=(-p 127.0.0.1::5432)
|
||||
PG_HOST=127.0.0.1
|
||||
fi
|
||||
docker run -d --name "$NAME" --rm \
|
||||
-e POSTGRES_USER=mmapp \
|
||||
-e POSTGRES_PASSWORD=mmapp \
|
||||
-e POSTGRES_DB=mmapp \
|
||||
"${NETWORK_ARGS[@]}" \
|
||||
postgres:18-alpine
|
||||
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||
if [ "$PG_HOST" = 127.0.0.1 ]; then
|
||||
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
|
||||
fi
|
||||
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||
for i in $(seq 1 40); do
|
||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL не поднялся"
|
||||
docker logs "$NAME" || true
|
||||
exit 1
|
||||
|
||||
- name: Install and test backend
|
||||
shell: bash
|
||||
env:
|
||||
REQUIRE_TEST_POSTGRES: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
node --input-type=module <<'NODE'
|
||||
import pg from 'pg';
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
console.log('PostgreSQL reachable from the test process');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
NODE
|
||||
npm run test --prefix backend
|
||||
|
||||
- name: Stop PostgreSQL
|
||||
if: always()
|
||||
shell: bash
|
||||
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
|
||||
|
||||
backend-image:
|
||||
needs: prepare-release
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -61,22 +141,35 @@ jobs:
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||
cp package.json package-lock.json "$STAGING/"
|
||||
cp package-lock.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*", "backend"]
|
||||
pkg.dependencies = {}
|
||||
pkg.devDependencies = {}
|
||||
delete pkg.scripts
|
||||
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
||||
cp -R backend/src "$STAGING/backend/"
|
||||
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||
if [ -d backend/drizzle ]; then
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -161,7 +254,7 @@ jobs:
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -177,6 +270,7 @@ jobs:
|
||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||
tags: |
|
||||
@@ -214,7 +308,7 @@ jobs:
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
@@ -238,12 +332,69 @@ jobs:
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
netflow-agent-image:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure netflow-agent image name
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||
name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||
echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-netflow-agent" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Test controller and installer
|
||||
run: |
|
||||
python3 -m unittest discover -s deploy/netflow-agent -p "test_*.py" -v
|
||||
bash -n deploy/netflow-agent/install.sh
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Verify image with real WireGuard
|
||||
run: |
|
||||
docker build --target test -t mm-netflow-agent-test deploy/netflow-agent
|
||||
docker run --rm --cap-drop ALL --cap-add NET_ADMIN --read-only --tmpfs /tmp -e FLOW_AGENT_KERNEL_TEST=1 --entrypoint python3 mm-netflow-agent-test -m unittest discover -s /app -p "test_agent.py" -v
|
||||
|
||||
- name: Build and push netflow-agent image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: deploy/netflow-agent
|
||||
file: deploy/netflow-agent/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
|
||||
cache-from: type=gha,scope=netflow-agent
|
||||
cache-to: type=gha,mode=max,scope=netflow-agent
|
||||
labels: |
|
||||
org.opencontainers.image.title=mmapp-netflow-agent
|
||||
org.opencontainers.image.description=MikroTik Manager local WireGuard controller
|
||||
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- updater-image
|
||||
- netflow-agent-image
|
||||
if: needs.prepare-release.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -325,17 +476,18 @@ jobs:
|
||||
fi
|
||||
|
||||
notify-webhook:
|
||||
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
|
||||
needs:
|
||||
- prepare-release
|
||||
- backend-image
|
||||
- frontend-image
|
||||
- netflow-agent-image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Notify deploy webhook
|
||||
if: env.WEBHOOK_URL != ''
|
||||
shell: bash
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl --fail --silent --show-error --request POST \
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
name: MCP isolated acceptance preview images
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact reviewed full commit SHA on the selected ref; includes all accepted source changes
|
||||
required: true
|
||||
type: string
|
||||
auth_portal_origin:
|
||||
description: Canonical isolated browser-facing Auth Portal HTTP(S) origin; no path, credentials or query
|
||||
required: true
|
||||
type: string
|
||||
default: http://127.0.0.1:55175
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
jobs:
|
||||
backend-preview:
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
COMPONENT: backend
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
DISPATCH_SHA: ${{ gitea.sha }}
|
||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||
DISPATCH_REF: ${{ gitea.ref }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
RUN_ID: ${{ gitea.run_id }}
|
||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||
steps:
|
||||
- name: Checkout exact dispatch commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.sha }}
|
||||
persist-credentials: false
|
||||
- name: Use existing Linux Node22 build toolchain
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Verify source and isolated preview destination
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
git diff --exit-code
|
||||
git diff --cached --exit-code
|
||||
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
||||
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
|
||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
|
||||
mkdir -p .ci/acceptance-evidence
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
|
||||
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
|
||||
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
|
||||
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
|
||||
NODE
|
||||
- name: Prepare existing scoped Docker context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||
cp package-lock.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*", "backend"]
|
||||
pkg.dependencies = {}
|
||||
pkg.devDependencies = {}
|
||||
delete pkg.scripts
|
||||
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json backend/mcp-coverage.json "$STAGING/backend/"
|
||||
cp -R backend/src "$STAGING/backend/"
|
||||
mkdir -p "$STAGING/deploy/netflow-agent"
|
||||
cp deploy/netflow-agent/install.sh "$STAGING/deploy/netflow-agent/"
|
||||
if [ -d backend/drizzle ]; then
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
node backend/scripts/stage-database-operator.mjs "$STAGING/backend/scripts"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to existing Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Build and push isolated acceptance image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .ci/docker/backend
|
||||
file: backend/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
|
||||
build-args: APP_VERSION=${{ env.PREVIEW_TAG }}
|
||||
cache-from: type=gha,scope=mcp-acceptance-backend
|
||||
cache-to: type=gha,scope=mcp-acceptance-backend,mode=max
|
||||
provenance: mode=min
|
||||
sbom: true
|
||||
labels: |-
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
|
||||
io.mmapp.acceptance-only=true
|
||||
- name: Record exact digest and build metadata
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
||||
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
|
||||
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
|
||||
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
|
||||
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
|
||||
NODE
|
||||
- name: Retain source and image binding artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .ci/acceptance-evidence/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
frontend-preview:
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/codex/mcp-acceptance-current-20261103' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
COMPONENT: frontend
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
DISPATCH_SHA: ${{ gitea.sha }}
|
||||
DISPATCH_EVENT: ${{ gitea.event_name }}
|
||||
DISPATCH_REF: ${{ gitea.ref }}
|
||||
REPOSITORY: ${{ gitea.repository }}
|
||||
RUN_ID: ${{ gitea.run_id }}
|
||||
RUN_ATTEMPT: ${{ gitea.run_attempt }}
|
||||
AUTH_PORTAL_ORIGIN_REQUEST: ${{ inputs.auth_portal_origin }}
|
||||
steps:
|
||||
- name: Checkout exact dispatch commit
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.sha }}
|
||||
persist-credentials: false
|
||||
- name: Use existing Linux Node22 build toolchain
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Validate explicit isolated portal origin
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),assert=require('node:assert/strict');
|
||||
const raw=process.env.AUTH_PORTAL_ORIGIN_REQUEST;
|
||||
assert(typeof raw==='string'&&raw.length>0&&raw.length<=2048&&!/[\x00-\x20\x7f]/.test(raw),'Bounded canonical HTTP(S) origin required');
|
||||
const origin=new URL(raw);
|
||||
assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.pathname==='/'&&!origin.search&&!origin.hash&&origin.origin!=='null'&&origin.origin===raw,'Canonical origin only; credentials/path/query/hash refused');
|
||||
fs.appendFileSync(process.env.GITHUB_ENV,'AUTH_PORTAL_ORIGIN='+origin.origin+'\n');
|
||||
NODE
|
||||
- name: Verify source and isolated preview destination
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$DISPATCH_EVENT" = workflow_dispatch
|
||||
test "$DISPATCH_REF" = refs/heads/codex/mcp-acceptance-current-20261103
|
||||
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
test "$EXPECTED_SHA" = "$DISPATCH_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
git diff --exit-code
|
||||
git diff --cached --exit-code
|
||||
[[ "$RUN_ID" =~ ^[0-9]+$ && "$RUN_ATTEMPT" =~ ^[0-9]+$ ]]
|
||||
owner=$(printf '%s' "$REPOSITORY" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
|
||||
name=$(printf '%s' "$REPOSITORY" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$owner" =~ ^[a-z0-9][a-z0-9._-]*$ && "$name" =~ ^[a-z0-9][a-z0-9._-]*$ ]]
|
||||
stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//')
|
||||
echo "IMAGE_NAME=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_TAG=mcp-acceptance-$EXPECTED_SHA-$RUN_ID-$RUN_ATTEMPT" >> "$GITHUB_ENV"
|
||||
echo "PREVIEW_CACHE=git.shx.one/$owner/$stem-$COMPONENT-mcp-acceptance-buildcache:node-buildcache" >> "$GITHUB_ENV"
|
||||
mkdir -p .ci/acceptance-evidence
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),cp=require('node:child_process'),crypto=require('node:crypto');
|
||||
const files=cp.execFileSync('git',['ls-files','-z'],{encoding:'utf8'}).split('\0').filter(Boolean);
|
||||
const inputs=files.map(path=>{const info=fs.lstatSync(path);if(!info.isFile()||info.isSymbolicLink())throw Error('Regular tracked source inputs required');const data=fs.readFileSync(path);return {path,sha256:crypto.createHash('sha256').update(data).digest('hex'),bytes:data.length}});
|
||||
fs.writeFileSync('.ci/acceptance-evidence/source.json',JSON.stringify({format:'mcp-preview-source-v1',repository:process.env.REPOSITORY,sourceSha:process.env.EXPECTED_SHA,sourceTree:cp.execFileSync('git',['rev-parse','HEAD^{tree}'],{encoding:'utf8'}).trim(),platform:'linux/amd64',component:process.env.COMPONENT,runId:process.env.RUN_ID,runAttempt:process.env.RUN_ATTEMPT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,inputs},null,2)+'\n');
|
||||
NODE
|
||||
- name: Prepare existing scoped Docker context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAGING=".ci/docker/frontend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
|
||||
cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*"]
|
||||
fs.writeFileSync(".ci/docker/frontend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
for dir in app components lib shared entities public hooks; do
|
||||
if [ -d "$dir" ]; then
|
||||
cp -R "$dir" "$STAGING/"
|
||||
fi
|
||||
done
|
||||
test -f "$STAGING/public/release-manifest.json"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to existing Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Build and push isolated acceptance image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .ci/docker/frontend
|
||||
file: Dockerfile.frontend
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.PREVIEW_TAG }}
|
||||
build-args: |-
|
||||
BACKEND_INTERNAL_URL=http://backend:8000
|
||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=${{ env.AUTH_PORTAL_ORIGIN }}
|
||||
NEXT_PUBLIC_APP_VERSION=${{ env.PREVIEW_TAG }}
|
||||
NEXT_PUBLIC_RELEASE_URL=
|
||||
cache-from: type=gha,scope=mcp-acceptance-frontend
|
||||
cache-to: type=gha,scope=mcp-acceptance-frontend,mode=max
|
||||
provenance: mode=min
|
||||
sbom: true
|
||||
labels: |-
|
||||
org.opencontainers.image.revision=${{ gitea.sha }}
|
||||
org.opencontainers.image.version=${{ env.PREVIEW_TAG }}
|
||||
io.mmapp.acceptance-only=true
|
||||
- name: Record exact digest and build metadata
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
|
||||
BUILD_METADATA: ${{ steps.build.outputs.metadata }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'NODE'
|
||||
const fs=require('node:fs'),crypto=require('node:crypto');
|
||||
const digest=process.env.IMAGE_DIGEST;if(!/^sha256:[a-f0-9]{64}$/.test(digest))throw Error('Exact registry digest required');
|
||||
const metadata=JSON.parse(process.env.BUILD_METADATA);const source=fs.readFileSync('.ci/acceptance-evidence/source.json');
|
||||
fs.writeFileSync('.ci/acceptance-evidence/image.json',JSON.stringify({format:'mcp-preview-image-v1',sourceSha:process.env.EXPECTED_SHA,sourceProofSha256:crypto.createHash('sha256').update(source).digest('hex'),image:process.env.IMAGE_NAME,tag:process.env.PREVIEW_TAG,digest,pinnedReference:process.env.IMAGE_NAME+'@'+digest,platform:'linux/amd64',component:process.env.COMPONENT,authPortalOrigin:process.env.AUTH_PORTAL_ORIGIN??null,buildMetadata:metadata,productionDeployed:false,wholeInstallation2GbAccepted:false},null,2)+'\n');
|
||||
console.log('Acceptance image digest: '+process.env.IMAGE_NAME+'@'+digest);
|
||||
NODE
|
||||
- name: Retain source and image binding artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mcp-acceptance-${{ env.COMPONENT }}-${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
path: .ci/acceptance-evidence/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
+29
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
||||
|
||||
# coverage
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -48,4 +52,29 @@ Thumbs.db
|
||||
.vercel/
|
||||
tmp/
|
||||
temp/
|
||||
.mcp-state/
|
||||
backend/.mcp-state/
|
||||
.ci/docker/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
.mcp.json
|
||||
opencode.json
|
||||
|
||||
# Local codegraph process
|
||||
.codegraph/daemon.pid
|
||||
|
||||
# Python controller tests
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# NetFlow agent host state (when Compose is installed in a checkout)
|
||||
/data/netflow-wireguard/
|
||||
/data/netflow-agent-socket/
|
||||
# Compiled disposable memory-acceptance entry; not a production image artifact.
|
||||
backend/dist-mcp-memory/
|
||||
backend/dist-operator/
|
||||
|
||||
# Agent-local permissions can contain commands with deployment credentials.
|
||||
/.claude/settings.local.json
|
||||
@@ -10,3 +10,14 @@
|
||||
4. Локальный hook `.githooks/commit-msg` отклоняет subject без кириллицы; подключение — `npm install` / `npm run prepare`.
|
||||
|
||||
Полные правила: `.cursor/rules/commit-messages-ru.mdc`, semver — `.cursor/rules/release-versioning.mdc`.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
Два процесса из корня: `npm run dev` (frontend :3000) и `npm --prefix backend run dev` (backend :8000). Перед первым запуском — `npm install`, для backend — `backend/.env` из `backend/.env.example`. Подробности — `.cursor/rules/dev-run-command.mdc` и `README.md`.
|
||||
|
||||
## ReUI
|
||||
|
||||
Skill: `.agents/skills/reui` (Codex), `.claude/skills/reui`, `.cursor/skills/reui`, `.opencode/skills/reui` — версия `fe6742e29c` (22 free components).
|
||||
Codex MCP: `.codex/config.toml`, стиль `base-nova`, авторизация через переменную окружения `REUI_LICENSE_KEY`. Для PRO-реестра shadcn тот же ключ хранится в `.env.local` (исключён из Git); реестр настроен в `components.json`. После изменения переменной окружения перезапустить Codex.
|
||||
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills) · [Cursor](https://reui.io/docs/cursor).
|
||||
Обновление skill: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
||||
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
||||
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
|
||||
COPY packages/contracts packages/contracts
|
||||
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||
COPY app app
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
||||
|-----------|------|------|----------------|--------------|
|
||||
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` |
|
||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
|
||||
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
||||
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
||||
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
||||
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shts.su` |
|
||||
| CI | `.gitea/workflows/docker.yml` | Gitea Actions, Buildx | — | push в `git.shx.one` |
|
||||
|
||||
Имена образов в registry для репозитория `denozord/MikrotikManager-3`:
|
||||
|
||||
- `git.shts.su/denozord/mikrotikmanager-backend`
|
||||
- `git.shts.su/denozord/mikrotikmanager-frontend`
|
||||
- `git.shts.su/denozord/mikrotikmanager-updater`
|
||||
- `git.shx.one/denozord/mikrotikmanager-backend`
|
||||
- `git.shx.one/denozord/mikrotikmanager-frontend`
|
||||
- `git.shx.one/denozord/mikrotikmanager-updater`
|
||||
|
||||
Для другого owner/repo подставьте имя по правилу CI (см. [CI/CD](#cicd-gitea-actions)).
|
||||
|
||||
@@ -43,24 +43,25 @@ flowchart TB
|
||||
Browser["Браузер"]
|
||||
NextDev["Next.js :3000"]
|
||||
FastifyDev["Fastify :8000"]
|
||||
SqliteDev["SQLite файл"]
|
||||
PostgresDev["PostgreSQL 18"]
|
||||
Contracts["@mmapp/contracts"]
|
||||
Browser --> NextDev
|
||||
Browser -->|"fetch /api, CORS"| FastifyDev
|
||||
FastifyDev --> SqliteDev
|
||||
FastifyDev --> PostgresDev
|
||||
Contracts --> NextDev
|
||||
Contracts --> FastifyDev
|
||||
end
|
||||
|
||||
subgraph ci [Gitea Actions]
|
||||
WF[".gitea/workflows/docker.yml"]
|
||||
WF --> Reg["git.shts.su registry"]
|
||||
WF --> Reg["git.shx.one registry"]
|
||||
end
|
||||
|
||||
subgraph prod [Прод-сервер]
|
||||
FE["mmapp-frontend :3000"]
|
||||
BE["mmapp-backend :8000"]
|
||||
DBVol["volume backend-data /app/data"]
|
||||
PG["mmapp-postgres"]
|
||||
DBVol["volume sqlite ETL /app/data"]
|
||||
UPD["mmapp-updater"]
|
||||
Sock["/var/run/docker.sock"]
|
||||
Reg --> FE
|
||||
@@ -69,6 +70,7 @@ flowchart TB
|
||||
UPD --> Sock
|
||||
UPD --> FE
|
||||
UPD --> BE
|
||||
BE --> PG
|
||||
BE --> DBVol
|
||||
BrowserProd["Клиент"] --> FE
|
||||
BrowserProd -->|"live + backendUrl"| BE
|
||||
@@ -117,7 +119,7 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
||||
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
||||
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
||||
|
||||
@@ -125,7 +127,8 @@ sequenceDiagram
|
||||
|
||||
| Зависимость | Реализация |
|
||||
|-------------|------------|
|
||||
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data` → `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). |
|
||||
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
|
||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||
|
||||
## Локальная разработка
|
||||
@@ -134,7 +137,9 @@ sequenceDiagram
|
||||
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
|
||||
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
|
||||
@@ -160,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|------------|--------------|------------|
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
|
||||
| `DATABASE_URL` | `postgres://mmapp:mmapp@127.0.0.1:5432/mmapp` | подключение к PostgreSQL |
|
||||
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
|
||||
| `PORT` | `8000` | порт Fastify |
|
||||
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
||||
|
||||
@@ -178,23 +184,29 @@ Frontend — в репозитории нет корневого `.env.example`.
|
||||
npm run build -w @mmapp/contracts
|
||||
```
|
||||
|
||||
После изменения схем Drizzle:
|
||||
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
|
||||
|
||||
```bash
|
||||
npm --prefix backend run db:generate
|
||||
npm --prefix backend run db:migrate
|
||||
npm --prefix backend run db:studio
|
||||
npm --prefix backend run db:migrate-from-sqlite
|
||||
```
|
||||
|
||||
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
|
||||
|
||||
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
|
||||
|
||||
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
|
||||
|
||||
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
||||
|
||||
| Job | Build context | Dockerfile | Имя образа |
|
||||
|-----|---------------|------------|------------|
|
||||
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shts.su/<owner>/<stem>-backend` |
|
||||
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shts.su/<owner>/<stem>-frontend` |
|
||||
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shts.su/<owner>/<stem>-updater` |
|
||||
| `backend-image` | `.ci/docker/backend` (staging в CI) | `backend/Dockerfile` | `git.shx.one/<owner>/<stem>-backend` |
|
||||
| `frontend-image` | `.ci/docker/frontend` | `Dockerfile.frontend` | `git.shx.one/<owner>/<stem>-frontend` |
|
||||
| `updater-image` | `deploy/updater` | `deploy/updater/Dockerfile` | `git.shx.one/<owner>/<stem>-updater` |
|
||||
|
||||
- `<owner>` — первая часть `gitea.repository`, lower case.
|
||||
- `<stem>` — имя репозитория lower case без суффикса `-<цифры>` в конце (например `MikrotikManager-3` → `mikrotikmanager`).
|
||||
@@ -207,27 +219,83 @@ npm --prefix backend run db:studio
|
||||
|
||||
### Версионирование и релизы
|
||||
|
||||
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope).
|
||||
- Базовая версия: **`v1.0.0`**. Поддерживаются patch, minor и major; выпуск `2.0.0` инициируется breaking-коммитом, версия вычисляется CI.
|
||||
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
|
||||
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
||||
- **`!` после типа/scope или footer `BREAKING CHANGE:` / `BREAKING-CHANGE:`** → major (`2.0.0` после `1.x`); **`feat`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
|
||||
- Если после последнего тега нет новых коммитов, **релиз** пропускается; Docker-образы при этом всё равно собираются и публикуются с `:latest` и `:<sha>`.
|
||||
- Первый релиз без тега `v1.0.0` возможен автоматически: CI берёт историю `HEAD`, считает bump от `1.0.0` и создаёт тег (например `v1.1.0` при `feat:`).
|
||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shx.one` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
|
||||
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
|
||||
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
|
||||
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc); subject и body — **на русском**, префикс Conventional Commits — на английском.
|
||||
|
||||
## Прод-развёртывание Docker
|
||||
|
||||
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
|
||||
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
|
||||
|
||||
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
|
||||
|
||||
### CDN Manager + MikrotikManager (один Traefik)
|
||||
|
||||
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
|
||||
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
|
||||
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
|
||||
docker login git.shx.one
|
||||
cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||
curl -fsS https://cdn.shnt.top/health
|
||||
curl -fsS https://mm.shnt.top/health
|
||||
```
|
||||
|
||||
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
|
||||
|
||||
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
|
||||
|
||||
| Способ | Файл |
|
||||
|--------|------|
|
||||
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
|
||||
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
|
||||
|
||||
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
|
||||
|
||||
```bash
|
||||
# Compose (рекомендуется)
|
||||
mkdir -p /opt/mmapp/{data,state,updater}
|
||||
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
|
||||
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||
cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||
|
||||
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
|
||||
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||
chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||
```
|
||||
|
||||
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
|
||||
|
||||
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
|
||||
|
||||
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
|
||||
|
||||
### Прод-контейнеры
|
||||
|
||||
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
||||
|--------|------------------|----------------|----------------------|------|-----------|
|
||||
| backend | `mmapp-backend` | `git.shts.su/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` |
|
||||
| frontend | `mmapp-frontend` | `git.shts.su/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
||||
| updater | `mmapp-updater` | `git.shts.su/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
||||
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
|
||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
|
||||
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
||||
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
||||
|
||||
Метки для updater на backend и frontend:
|
||||
|
||||
@@ -241,11 +309,14 @@ npm --prefix backend run db:studio
|
||||
|
||||
### Переменные окружения (прод)
|
||||
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS):
|
||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
|
||||
|
||||
| Переменная | Источник в compose | Назначение |
|
||||
|------------|-------------------|------------|
|
||||
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
||||
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
|
||||
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
|
||||
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
|
||||
|
||||
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
||||
|
||||
@@ -256,16 +327,16 @@ npm --prefix backend run db:studio
|
||||
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
||||
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
||||
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
|
||||
| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
|
||||
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
||||
| `REGISTRY` | `git.shts.su` | registry для `docker login` |
|
||||
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
||||
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
||||
| `REGISTRY_PASSWORD` | пусто | пароль registry |
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker login git.shts.su
|
||||
docker login git.shx.one
|
||||
export CORS_ORIGIN=http://<хост>:3000
|
||||
export REGISTRY_USERNAME=<user>
|
||||
export REGISTRY_PASSWORD=<token>
|
||||
@@ -307,8 +378,8 @@ docker run -d \
|
||||
-v backend-data:/app/data \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=backend \
|
||||
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-backend:latest \
|
||||
git.shts.su/denozord/mikrotikmanager-backend:latest
|
||||
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-backend:latest \
|
||||
git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
@@ -320,8 +391,8 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=frontend \
|
||||
--label mmapp.updater.image=git.shts.su/denozord/mikrotikmanager-frontend:latest \
|
||||
git.shts.su/denozord/mikrotikmanager-frontend:latest
|
||||
--label mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:latest \
|
||||
git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
```
|
||||
|
||||
**Updater:**
|
||||
@@ -331,7 +402,7 @@ docker volume create updater-state
|
||||
docker run -d \
|
||||
--name mmapp-updater \
|
||||
--restart unless-stopped \
|
||||
-e REGISTRY=git.shts.su \
|
||||
-e REGISTRY=git.shx.one \
|
||||
-e REGISTRY_USERNAME=<user> \
|
||||
-e REGISTRY_PASSWORD=<token> \
|
||||
-e POLL_INTERVAL_SECONDS=300 \
|
||||
@@ -340,7 +411,7 @@ docker run -d \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v updater-state:/state \
|
||||
-v /path/to/targets.json:/etc/updater/targets.json:ro \
|
||||
git.shts.su/denozord/mikrotikmanager-updater:latest
|
||||
git.shx.one/denozord/mikrotikmanager-updater:latest
|
||||
```
|
||||
|
||||
### Проверка после деплоя
|
||||
@@ -399,7 +470,7 @@ bash deploy/updater/validate.sh
|
||||
|
||||
## Первичная настройка сервера
|
||||
|
||||
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shts.su`.
|
||||
Пошагово на чистом Linux-хосте с доступом в интернет и к `git.shx.one`.
|
||||
|
||||
1. Установить Docker Engine и плагин Compose (официальная документация Docker для вашего дистрибутива).
|
||||
2. Проверить:
|
||||
@@ -413,7 +484,7 @@ docker compose version
|
||||
4. Войти в registry:
|
||||
|
||||
```bash
|
||||
docker login git.shts.su
|
||||
docker login git.shx.one
|
||||
```
|
||||
|
||||
5. Получить файлы деплоя: клонировать репозиторий или скопировать каталог `deploy/` и подготовить `deploy/updater/targets.json`.
|
||||
@@ -443,8 +514,8 @@ Updater опрашивает registry с интервалом `POLL_INTERVAL_SEC
|
||||
### Ручное обновление
|
||||
|
||||
```bash
|
||||
docker pull git.shts.su/denozord/mikrotikmanager-backend:latest
|
||||
docker pull git.shts.su/denozord/mikrotikmanager-frontend:latest
|
||||
docker pull git.shx.one/denozord/mikrotikmanager-backend:latest
|
||||
docker pull git.shx.one/denozord/mikrotikmanager-frontend:latest
|
||||
```
|
||||
|
||||
Через compose (пересоздание при смене образа):
|
||||
@@ -460,18 +531,20 @@ docker compose up -d --force-recreate
|
||||
### Откат
|
||||
|
||||
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`.
|
||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
|
||||
|
||||
### Резервное копирование SQLite
|
||||
### Резервное копирование PostgreSQL
|
||||
|
||||
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии:
|
||||
Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
|
||||
|
||||
```bash
|
||||
docker stop mmapp-backend
|
||||
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
|
||||
docker start mmapp-backend
|
||||
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||
```
|
||||
|
||||
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
|
||||
|
||||
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
|
||||
|
||||
### Отладка
|
||||
|
||||
| Действие | Команда |
|
||||
@@ -485,7 +558,7 @@ docker start mmapp-backend
|
||||
|
||||
1. Установить Docker Engine и Compose plugin; проверить `docker version` и `docker compose version`.
|
||||
2. Открыть порты 3000 и 8000 (или обеспечить доступ клиентов к UI и API).
|
||||
3. Выполнить `docker login git.shts.su`.
|
||||
3. Выполнить `docker login git.shx.one`.
|
||||
4. Склонировать репозиторий или скопировать `deploy/`.
|
||||
5. Задать `CORS_ORIGIN` (origin фронтенда для браузера).
|
||||
6. Задать `REGISTRY_USERNAME` и `REGISTRY_PASSWORD` для updater при приватном registry.
|
||||
@@ -495,3 +568,7 @@ docker start mmapp-backend
|
||||
10. Проверить `curl` на `:8000/health` и `:3000/`.
|
||||
11. В UI: режим live и URL бэкенда; сверить с `CORS_ORIGIN`.
|
||||
12. Просмотреть `docker logs mmapp-updater`; настроить регулярный backup тома `backend-data`.
|
||||
|
||||
### Автоматизация NetFlow через Docker-агент
|
||||
|
||||
[Установка, схема взаимодействия и production-состав](docs/netflow-agent.md).
|
||||
+369
-729
File diff suppressed because it is too large.
Load diff
+54
-76
@@ -1,34 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { AsnsDataGrid } from "@/components/data-grids/asns-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { asns as mockAsns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function AsnsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockAsns
|
||||
if (mode === "mock") return mockAsns
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.asns ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.org.toLowerCase().includes(q) ||
|
||||
(r.prefixes === null ? "" : String(r.prefixes)).includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||
</>
|
||||
@@ -41,71 +56,34 @@ export default function AsnsPage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Автономные системы — источники маршрутов для BGP-фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
searchKeys={["asn", "org", "prefixes"]}
|
||||
columns={[
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "org",
|
||||
label: "Имя / организация",
|
||||
render: (d) => (
|
||||
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
|
||||
{d.org}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prefixes",
|
||||
label: "Префиксов",
|
||||
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||
countLabel={known ? `${filtered.length} ASN` : "Количество: —"}
|
||||
/>
|
||||
{known || loading ? <AsnsDataGrid
|
||||
asns={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт ASN"
|
||||
description="Загрузите CSV или JSON со списком автономных систем"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+537
-743
File diff suppressed because it is too large.
Load diff
+362
-480
File diff suppressed because it is too large.
Load diff
+358
-821
File diff suppressed because it is too large.
Load diff
+126
-197
@@ -1,39 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { useEffect, useRef, useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
CommunitiesDataGrid,
|
||||
type CommunityRow,
|
||||
TYPE_LABELS,
|
||||
ACTION_LABELS,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
LoaderCircleIcon,
|
||||
PlusIcon, SearchIcon, TagIcon, FilterIcon,
|
||||
CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { filters } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
interface Community {
|
||||
id: string
|
||||
value: string // e.g. "65001:100"
|
||||
name: string
|
||||
description: string
|
||||
type: CommType
|
||||
filterIds: string[] // which filters use this community
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number // e.g. local-pref value
|
||||
enabled: boolean
|
||||
}
|
||||
type Community = CommunityRow
|
||||
type CommType = CommunityRow["type"]
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,49 +94,34 @@ const COMMUNITIES: Community[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<CommType, string> = {
|
||||
"standard": "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise":"No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
"custom": "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<Community["action"], string> = {
|
||||
"permit": "Permit",
|
||||
"deny": "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
"metric": "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
"permit": "text-emerald-500",
|
||||
"deny": "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
"metric": "text-amber-500",
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const { sourceKey } = useEvoBGP()
|
||||
return <CommunitiesContent key={sourceKey} />
|
||||
}
|
||||
function CommunitiesContent() {
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Community | null>(null)
|
||||
const [copying, setCopying] = useState(false)
|
||||
const [copyError, setCopyError] = useState<string | null>(null)
|
||||
const copyRequest = useRef({ active: true, pending: false, revision: 0, timer: null as ReturnType<typeof setTimeout> | null })
|
||||
useEffect(() => {
|
||||
const state = copyRequest.current; state.active = true
|
||||
return () => { state.active = false; state.revision++; if (state.timer) clearTimeout(state.timer) }
|
||||
}, [])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const listData = useMemo((): Community[] => {
|
||||
if (!useEvoCatalog) return COMMUNITIES
|
||||
if (mode === "mock") return COMMUNITIES
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.communities ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null)
|
||||
}, [useEvoCatalog])
|
||||
const selected = known ? listData.find(row => row.id === selectedId) ?? null : null
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
@@ -153,10 +132,24 @@ export default function CommunitiesPage() {
|
||||
})
|
||||
}, [search, typeFilter, listData])
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
setCopied(value)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
const handleCopy = async (value: string) => {
|
||||
const state = copyRequest.current
|
||||
if (!state.active || state.pending) return
|
||||
state.pending = true; const revision = ++state.revision
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
const current = () => state.active && state.revision === revision
|
||||
setCopying(true); setCopied(null); setCopyError(null)
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
if (!current()) return
|
||||
setCopied(value)
|
||||
state.timer = setTimeout(() => { if (current()) setCopied(null) }, 1500)
|
||||
} catch {
|
||||
if (current()) setCopyError("Браузер не разрешил запись в буфер обмена. Повторите копирование или выделите значение вручную.")
|
||||
} finally {
|
||||
state.pending = false
|
||||
if (current()) setCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||
@@ -165,8 +158,7 @@ export default function CommunitiesPage() {
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
actions={mode === "live" ? null : <Button size="sm">
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
}
|
||||
@@ -174,38 +166,46 @@ export default function CommunitiesPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs flex items-center gap-1.5 -mt-1",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
<EvoBgpCatalogStatus />
|
||||
{copyError && <Alert variant="warning"><AlertTitle>Community не скопировано</AlertTitle><AlertDescription>{copyError}</AlertDescription></Alert>}
|
||||
{copying && <p role="status" className="text-xs text-muted-foreground">Копирование community…</p>}
|
||||
{copied && <p role="status" className="text-xs text-muted-foreground">Community {copied} скопировано</p>}
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(listData.length) },
|
||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка communities"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего communities",
|
||||
value: known ? String(listData.length) : "—",
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Активных",
|
||||
value: known ? String(listData.filter((c) => c.enabled).length) : "—",
|
||||
icon: <CheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
label: "Стандартных",
|
||||
value: known ? String(listData.filter((c) => c.type === "standard").length) : "—",
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Использует фильтры",
|
||||
value: mode === "mock" ? String(new Set(listData.flatMap((c) => c.filterIds)).size) : "—",
|
||||
icon: <FilterIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-5">
|
||||
{/* ── main table ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* toolbar */}
|
||||
@@ -214,7 +214,7 @@ export default function CommunitiesPage() {
|
||||
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||
@@ -225,119 +225,49 @@ export default function CommunitiesPage() {
|
||||
</div>
|
||||
|
||||
{/* list */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(c => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setSelected(c)}
|
||||
className={cn(
|
||||
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
selected?.id === c.id && "bg-primary/5",
|
||||
!c.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copied === c.value
|
||||
? <CheckIcon className="size-3" />
|
||||
: <CopyIcon className="size-3" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-xs">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
|
||||
{c.prefixCount.toLocaleString("ru-RU")}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<TagIcon className="size-8 opacity-30" />
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<DataPageCard>
|
||||
{known ? <CommunitiesDataGrid
|
||||
communities={filtered}
|
||||
selectedId={selected?.id}
|
||||
copiedValue={copied}
|
||||
copying={copying}
|
||||
onSelect={row => setSelectedId(row.id)}
|
||||
onCopy={handleCopy}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
|
||||
{/* ── detail panel ── */}
|
||||
{selected ? (
|
||||
<Card className="h-fit sticky top-0">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
selected.enabled
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{selected.enabled ? "Активен" : "Выключен"}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
|
||||
</div>
|
||||
<OpsPanel
|
||||
className="h-fit sticky top-0"
|
||||
title={<span className="font-mono">{selected.value}</span>}
|
||||
description={selected.name}
|
||||
headerRight={
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" aria-label="Изменить community" disabled className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" aria-label="Удалить community" disabled className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||
</div>
|
||||
}
|
||||
contentClassName="flex flex-col gap-4 text-sm px-5 pb-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
selected.enabled
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{selected.enabled ? "Активен" : "Выключен"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
["Тип", TYPE_LABELS[selected.type]],
|
||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
||||
["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"],
|
||||
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-2">
|
||||
@@ -363,20 +293,19 @@ export default function CommunitiesPage() {
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t flex flex-col gap-2">
|
||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" disabled={copying} onClick={() => { void handleCopy(selected.value) }}>
|
||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
Скопировать значение
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
) : (
|
||||
<Card className="h-fit">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Frame dense className="w-full h-fit">
|
||||
<FramePanel className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<TagIcon className="size-8 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+406
-238
@@ -1,13 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||
import type { RouterContainer, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
@@ -15,161 +22,115 @@ import {
|
||||
import {
|
||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon,
|
||||
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||
import { mutateContainer, type ContainerAction, readContainersState } from "@/shared/api/containers"
|
||||
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||
import type { ContainerServerState, ContainersState } from "@mmapp/contracts/containers"
|
||||
import { can } from "@/lib/auth"
|
||||
import { userApiError } from "@/shared/api/users"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
type ContainerView = Omit<RouterContainer, "status" | "startOnBoot"> & { status: RouterContainer["status"] | "unknown"; startOnBoot: boolean | null }
|
||||
type ContainerServer = Omit<Server, "status"> & {status: Server["status"] | null}
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type?: Server["type"]
|
||||
site?: string
|
||||
country: string
|
||||
asn?: string
|
||||
enabled: boolean
|
||||
status?: Server["status"] | null
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
function statusConfig(status: RouterContainer["status"]) {
|
||||
function mapBackendServer(s: BackendServer): ContainerServer {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site ?? "",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: s.status ?? null,
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function statusConfig(status: ContainerView["status"]) {
|
||||
return {
|
||||
unknown: { dot: "bg-muted-foreground", badge: "bg-muted text-muted-foreground border-border", label: "Неизвестно" },
|
||||
running: {
|
||||
dot: "bg-emerald-500 animate-pulse",
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
label: "Running",
|
||||
dot: "bg-success",
|
||||
badge: "bg-success/10 text-success border-success/20",
|
||||
label: "Работает",
|
||||
},
|
||||
stopped: {
|
||||
dot: "bg-muted-foreground",
|
||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||
label: "Stopped",
|
||||
label: "Остановлен",
|
||||
},
|
||||
error: {
|
||||
dot: "bg-red-500 animate-pulse",
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
label: "Error",
|
||||
dot: "bg-destructive",
|
||||
badge: "bg-destructive/10 text-destructive border-destructive/20",
|
||||
label: "Ошибка",
|
||||
},
|
||||
}[status]
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateContainerRsc(c: RouterContainer): string {
|
||||
const srv = serverFor(c.serverId)
|
||||
const lines: string[] = []
|
||||
lines.push(`# RouterOS Container — ${c.name}`)
|
||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
||||
lines.push(`# RouterOS 7.4+ · /container`)
|
||||
lines.push(``)
|
||||
|
||||
// interface
|
||||
for (const iface of c.interfaces) {
|
||||
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
||||
}
|
||||
lines.push(``)
|
||||
|
||||
// envs
|
||||
if (c.envs.length > 0) {
|
||||
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
||||
for (const { key, value } of c.envs) {
|
||||
lines.push(` ${key}="${value}" \\`)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// mounts
|
||||
for (const m of c.mounts) {
|
||||
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
||||
if (m.src) lines.push(` src=${m.src} \\`)
|
||||
lines.push(` dst=${m.dst}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// container
|
||||
lines.push(`/container/add \\`)
|
||||
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
||||
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
||||
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
|
||||
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
|
||||
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
|
||||
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
|
||||
if (c.comment) lines.push(` comment="${c.comment}" \\`)
|
||||
lines.push(` logging=yes`)
|
||||
|
||||
return lines.join("\n")
|
||||
type ContainerExport = { container: ContainerView; collectedAt: string | null; failures: ContainerServerState["failures"]; source: "live" | "mock" }
|
||||
function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; onClose: () => void}) {
|
||||
const formats = useMemo(() => [{
|
||||
id: "json", label: "Снимок JSON", filename: "container-" + (selection?.container.id ?? "snapshot").replace(/[^a-zA-Z0-9_-]/g, "_") + ".json",
|
||||
code: selection ? JSON.stringify(selection, null, 2) : "",
|
||||
}], [selection])
|
||||
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||||
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||
formats={formats} />
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, container, onClose }: {
|
||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт Container</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Container card ───────────────────────────────────────────────────────────
|
||||
|
||||
function ContainerCard({
|
||||
container,
|
||||
server,
|
||||
live,
|
||||
busy,
|
||||
onExport,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
onRemove,
|
||||
}: {
|
||||
container: RouterContainer
|
||||
container: ContainerView
|
||||
server?: ContainerServer
|
||||
live: boolean
|
||||
busy: boolean
|
||||
onExport: () => void
|
||||
onStart: () => void
|
||||
onStop: () => void
|
||||
onRestart: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const srv = serverFor(container.serverId)
|
||||
const cfg = statusConfig(container.status)
|
||||
const canMutate = live && Boolean(container.rosId) && !busy
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<Frame dense className="w-full overflow-hidden">
|
||||
<FramePanel className="p-0 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||||
@@ -180,51 +141,56 @@ function ContainerCard({
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<Button variant="ghost" size="icon" aria-label={"Действия контейнера " + container.name} className="size-11 sm:size-8 shrink-0" disabled={busy}>
|
||||
<MoreHorizontalIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{container.status === "running" ? (
|
||||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||
<StopCircleIcon aria-hidden="true" className="size-4 text-warning" />Остановить
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onStart}>
|
||||
<PlayIcon aria-hidden="true" className="size-4 text-success" />Запустить
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled><TerminalIcon aria-hidden="true" className="size-4" />Логи</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled><PencilIcon aria-hidden="true" className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon aria-hidden="true" className="size-4" />Снимок JSON</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canMutate || container.status === "unknown"} onClick={onRestart}>
|
||||
<PowerIcon aria-hidden="true" className="size-4" />Перезапустить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||
<Trash2Icon className="size-4" />Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-4 py-3 flex flex-col gap-3">
|
||||
{/* image */}
|
||||
<div className="px-4 py-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80">
|
||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
||||
<BoxIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80 min-w-0 [overflow-wrap:anywhere]">
|
||||
{container.image || "Образ неизвестен"}{container.tag && <>:<span className="text-muted-foreground">{container.tag}</span></>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
{srv && (
|
||||
{server && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ServerIcon className="size-3.5 shrink-0" />
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono">{srv.name}</span>
|
||||
<ServerIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
||||
<Flag code={server.country} size={12} />
|
||||
<span className="font-mono truncate" title={server.name}>{server.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* uptime + stats */}
|
||||
{container.status === "running" && (
|
||||
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||
{container.uptime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ActivityIcon className="size-3 text-emerald-500" />
|
||||
<ActivityIcon aria-hidden="true" className="size-3 text-success" />
|
||||
<span>{container.uptime}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -232,7 +198,7 @@ function ContainerCard({
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU </span>
|
||||
<span className={cn("font-mono font-medium",
|
||||
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
||||
container.cpu > 50 ? "text-warning" : "text-foreground")}>
|
||||
{container.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -240,13 +206,12 @@ function ContainerCard({
|
||||
{container.memMb !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM </span>
|
||||
<span className="font-mono font-medium">{container.memMb} МБ</span>
|
||||
<span className="font-mono font-medium">{container.memMb} МиБ</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* interfaces */}
|
||||
{container.interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{container.interfaces.map((i) => (
|
||||
@@ -257,7 +222,6 @@ function ContainerCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mounts */}
|
||||
{container.mounts.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{container.mounts.map((m, idx) => (
|
||||
@@ -271,137 +235,335 @@ function ContainerCard({
|
||||
)}
|
||||
|
||||
{container.comment && (
|
||||
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
||||
<p className="text-xs text-muted-foreground border-t pt-2 [overflow-wrap:anywhere]">{container.comment}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function ContainersPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||
function ContainersSource() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
function ContainersContent() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const canRead = can("mm:dashboard:read")
|
||||
const canReadServers = can("mm:servers:read")
|
||||
const canWrite = can("mm:servers:write")
|
||||
const params = useSearchParams()
|
||||
const context = readContainersUrl(params)
|
||||
const search = context.search
|
||||
const statusFilter = context.status
|
||||
const selectedServerId = context.server
|
||||
const urlError = context.error
|
||||
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||
}
|
||||
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
||||
const [state, setState] = useState<ContainersState | null>(null)
|
||||
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||
const deleteReturnLabel = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||||
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [deleteTarget, busyId])
|
||||
const [operationError, setOperationError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||
const mutationLock = useRef(false)
|
||||
const mutationController = useRef<AbortController | null>(null)
|
||||
const alive = useRef(true)
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [serversError, setServersError] = useState<string | null>(null)
|
||||
const [readAt, setReadAt] = useState<number | null>(null)
|
||||
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||
const loadController = useRef<AbortController | null>(null)
|
||||
const loadRevision = useRef(0)
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return false
|
||||
loadController.current?.abort()
|
||||
const controller = new AbortController()
|
||||
loadController.current = controller
|
||||
const revision = ++loadRevision.current
|
||||
const current = () => !controller.signal.aborted && loadRevision.current === revision
|
||||
setLoading(true)
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
let stateComplete = !canRead
|
||||
const tasks = await Promise.allSettled([
|
||||
canRead ? readContainersState(backendUrl, { signal }).then(result => {
|
||||
stateComplete = result.servers.every(node => node.availability !== "error")
|
||||
if (current()) {
|
||||
setState(result); setReadAt(Date.now()); setLiveError(null)
|
||||
if (result.servers.every(node => node.availability !== "error")) setCommandNeedsRead(false)
|
||||
setLastStates(previous => Object.fromEntries(result.servers.map(node => [node.serverId, node.availability === "error" ? previous[node.serverId] ?? node : node])))
|
||||
}
|
||||
}).catch(error => { if (current()) setLiveError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
|
||||
const result = serverListSchema.parse(payload)
|
||||
if (new Set(result.map(server => server.id)).size !== result.length) throw new Error("Каталог серверов содержит дубли ID")
|
||||
if (current()) { setLiveServers(result.filter(server => server.enabled).map(mapBackendServer)); setServersReadAt(Date.now()); setServersError(null) }
|
||||
}).catch(error => { if (current()) setServersError(userApiError(error)); throw error }) : Promise.resolve(),
|
||||
])
|
||||
if (current()) setLoading(false)
|
||||
return current() && stateComplete && tasks.every(task => task.status === "fulfilled")
|
||||
}, [isLive, backendUrl, canRead, canReadServers])
|
||||
useEffect(() => { let cancelled = false; queueMicrotask(() => { if (!cancelled) void loadLive() }); return () => { cancelled = true; loadController.current?.abort() } }, [loadLive])
|
||||
const displayContainers: ContainerView[] = useMemo(() => isLive ? Object.values(lastStates).flatMap(node => node.containers) : mockContainers, [isLive, lastStates])
|
||||
const displayServers = isLive ? liveServers : mockServers.filter(server => server.enabled)
|
||||
const effectiveServerId = selectedServerId
|
||||
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||
const stale = isLive && !!liveError
|
||||
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||
const scoped = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||
}, [displayContainers, effectiveServerId])
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
)
|
||||
|
||||
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status ?? "unknown",
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: isLive && (!state || !state.servers.some(node => String(node.serverId) === s.id && node.availability !== "error")) ? "—" : String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||
}))
|
||||
), [displayServers, displayContainers, state, isLive])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return routerContainers.filter((c) => {
|
||||
return scoped.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.image.toLowerCase().includes(q) ||
|
||||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||||
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
})
|
||||
}, [search, statusFilter])
|
||||
}, [search, statusFilter, scoped, serverById])
|
||||
|
||||
const running = routerContainers.filter((c) => c.status === "running").length
|
||||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
||||
const running = scoped.filter((c) => c.status === "running").length
|
||||
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||
const errors = scoped.filter((c) => c.status === "error").length
|
||||
const statusKnown = known && scoped.every(container => container.status !== "unknown")
|
||||
const currentDeleteTarget = deleteTarget ? displayContainers.find(row => row.id === deleteTarget.id && row.rosId === deleteTarget.rosId && row.serverId === deleteTarget.serverId) : undefined
|
||||
const deleteStopped = currentDeleteTarget?.status === "stopped"
|
||||
|
||||
async function mutate(c: ContainerView, action: ContainerAction) {
|
||||
if (!isLive || !c.rosId || !canWrite || !nodeFresh(c.serverId) || mutationLock.current || !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId)) return
|
||||
if (action === "remove" && !displayContainers.some(row => row.id === c.id && row.rosId === c.rosId && row.serverId === c.serverId && row.status === "stopped")) return
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
setBusyId(c.id); setOperationError(null); setOperationNotice(null)
|
||||
try {
|
||||
await mutateContainer(backendUrl, c.serverId, c.rosId, action, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
if (!current()) return
|
||||
const labels = { start: "запуск", stop: "остановка", restart: "перезапуск", remove: "удаление" }
|
||||
const message = c.name + ": API подтвердил команду «" + labels[action] + "». Состояние проверяется повторным чтением."
|
||||
setOperationNotice(message)
|
||||
if (action === "remove") {
|
||||
setLastStates(previous => Object.fromEntries(Object.entries(previous).map(([id, node]) => [id, { ...node, containers: node.containers.filter(row => row.id !== c.id) }])))
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
toast.success("Команда подтверждена API.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice(message + " Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const uncertain = !(error instanceof ApiClientError && error.status >= 400 && error.status < 500)
|
||||
setCommandNeedsRead(uncertain)
|
||||
setOperationError(c.name + ": " + userApiError(error) + (uncertain ? " Результат команды не подтверждён; она могла выполниться частично. Обновите каталог перед новым действием." : " Команда отклонена API."))
|
||||
} finally {
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<BoxIcon className="size-4" />Новый контейнер
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
preserveSelection
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || loading || !canRead && !canReadServers || !!busyId}
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" disabled title="Создание контейнеров ещё не подключено">
|
||||
<BoxIcon aria-hidden="true" className="size-4" />Новый контейнер
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||||
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||||
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||
{isLive && !canReadServers && <p role="status">Для каталога серверов требуется mm:servers:read. Команды недоступны.</p>}
|
||||
{isLive && loading && !state && <p role="status">Загрузка контейнеров…</p>}
|
||||
{isLive && readAt && <p role="status" className="text-xs text-muted-foreground">Каталог контейнеров получен: {new Date(readAt).toLocaleTimeString("ru-RU")}</p>}
|
||||
{isLive && serversReadAt && <p role="status" className="text-xs text-muted-foreground">Серверы получены: {new Date(serversReadAt).toLocaleTimeString("ru-RU")}</p>}
|
||||
{isLive && liveError && <Alert variant="warning" role="alert" aria-label="Каталог контейнеров"><AlertDescription>{state ? "Последние данные сохранены." : "Контейнеры пока неизвестны."} {liveError}</AlertDescription></Alert>}
|
||||
{isLive && serversError && <Alert variant="warning" role="alert" aria-label="Серверы контейнеров"><AlertDescription>{serversReadAt ? "Последний каталог сохранён." : "Каталог серверов пока неизвестен."} {serversError}</AlertDescription></Alert>}
|
||||
{isLive && relevantStates?.map(node => node.availability === "error" || node.failures.length ? <Alert key={node.serverId} variant="warning" role="alert" aria-label={"Контейнеры сервера " + node.serverId}><AlertDescription>Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: {node.failures.map(f => f.resource + ": " + f.error).join("; ")}. {node.availability === "available" ? "Контейнеры получены; настройки загружены частично." : lastStates[node.serverId]?.containers.length ? "Последние контейнеры сохранены." : "Список неизвестен."}</AlertDescription></Alert> : node.availability === "unsupported" ? <p key={node.serverId} role="status">Сервер {serverById[String(node.serverId)]?.name ?? node.serverId}: пакет container недоступен.</p> : null)}
|
||||
{isLive && selectedServerId !== ALL_SERVERS_ID && !displayServers.some(server => server.id === selectedServerId) && <p role="alert">Выбранный сервер недоступен в каталоге. Выберите другое устройство; фильтр сохранён.</p>}
|
||||
{mode === "mock" && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
Моковые данные
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка контейнеров"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: known ? scoped.length : "—",
|
||||
icon: <BoxIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "running",
|
||||
label: "Работает",
|
||||
value: statusKnown ? running : "—",
|
||||
icon: <PlayIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "stopped",
|
||||
label: "Остановлен",
|
||||
value: statusKnown ? stopped : "—",
|
||||
icon: <StopCircleIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
label: "Ошибок",
|
||||
value: statusKnown ? errors : "—",
|
||||
icon: <AlertCircleIcon aria-hidden="true" className="size-4" />,
|
||||
iconClassName: "text-destructive",
|
||||
variant: errors > 0 ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||||
<div className="flex items-start gap-3 rounded-lg bg-info/5 border border-info/20 px-4 py-3 text-sm">
|
||||
<BoxIcon aria-hidden="true" className="size-5 text-info shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
|
||||
<p className="font-medium text-foreground">Контейнеры доступны с RouterOS 7.4+</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||
Интерфейсы veth создаются автоматически.
|
||||
Сеть veth настраивается на устройстве. Создание контейнеров в интерфейсе пока недоступно.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-0 w-full sm:w-auto">
|
||||
<SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, образу, серверу…"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
aria-label="Поиск контейнеров" placeholder="Поиск по имени, образу, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
||||
<div role="group" aria-label="Статус контейнеров" className="flex flex-wrap items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "running", "stopped", "error", "unknown"] as const).map((s) => (
|
||||
<button key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
type="button" aria-pressed={statusFilter === s} onClick={() => setStatusFilter(s)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||
statusFilter === s
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
||||
{s === "all" ? "Все" : s === "running" ? "Работает" : s === "stopped" ? "Остановлен" : s === "unknown" ? "Неизвестно" : "Ошибка"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{known ? filtered.length : "—"} контейнеров</span>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
{filtered.length === 0 ? (!known ? <p role="status">Список контейнеров пока неизвестен.</p> : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||
<BoxIcon aria-hidden="true" className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
||||
<p className="text-xs mt-1">{scoped.length ? "Измените фильтр или поиск" : "Подтверждённый каталог пуст"}</p>
|
||||
</div>
|
||||
) : (
|
||||
)) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<ContainerCard
|
||||
key={c.id}
|
||||
container={c}
|
||||
onExport={() => setExportContainer(c)}
|
||||
server={serverById[c.serverId]}
|
||||
live={isLive && !!nodeFresh(c.serverId)}
|
||||
busy={!!busyId}
|
||||
onExport={() => { const snapshot = lastStates[Number(c.serverId)]; setExportContainer({ container: c, collectedAt: snapshot?.collectedAt ?? null, failures: snapshot?.failures ?? [], source: isLive ? "live" : "mock" }) }}
|
||||
onStart={() => { void mutate(c, "start") }}
|
||||
onStop={() => { void mutate(c, "stop") }}
|
||||
onRestart={() => { void mutate(c, "restart") }}
|
||||
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7.4+ · /container — быстрые команды
|
||||
</p>
|
||||
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
@@ -451,23 +613,29 @@ export default function ContainersPage() {
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
<pre className="bg-muted rounded-md p-2.5 text-foreground text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportContainer}
|
||||
container={exportContainer}
|
||||
onClose={() => setExportContainer(null)}
|
||||
/>
|
||||
</div>
|
||||
{deleteTarget && <AlertDialog open onOpenChange={open => { if (!open && !busyId) { setDeleteTarget(null); setOperationError(null) } }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>Удалить контейнер?</AlertDialogTitle><AlertDialogDescription>{deleteTarget.name} · сервер {serverById[deleteTarget.serverId]?.name ?? deleteTarget.serverId}. {deleteStopped ? "Будет удалён остановленный контейнер. Дополнительная очистка файлов не выполняется." : "Сначала закройте это окно, остановите контейнер через меню действий и обновите каталог. Удаление доступно только после подтверждённой остановки."}</AlertDialogDescription></AlertDialogHeader>
|
||||
{operationError && <p role="alert" aria-label="Удаление контейнера" className="text-sm text-destructive [overflow-wrap:anywhere]">{operationError}</p>}
|
||||
<AlertDialogFooter>
|
||||
{(commandNeedsRead || !deleteStopped) && <Button type="button" variant="outline" disabled={loading || !!busyId} onClick={() => { void loadLive() }}>Обновить каталог</Button>}
|
||||
<AlertDialogCancel disabled={!!busyId}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction type="button" variant="destructive" disabled={!!busyId || !deleteStopped || !nodeFresh(deleteTarget.serverId)} aria-busy={!!busyId} onClick={() => { void mutate(deleteTarget, "remove") }}>{busyId ? "Удаление…" : "Удалить"}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>}
|
||||
<ExportSheet selection={exportContainer} onClose={() => setExportContainer(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+315
-1055
File diff suppressed because it is too large.
Load diff
+547
-965
File diff suppressed because it is too large.
Load diff
+55
-80
@@ -1,34 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DomainsDataGrid } from "@/components/data-grids/domains-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { domains as mockDomains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockDomains
|
||||
if (mode === "mock") return mockDomains
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.domains ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.domain.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||
</>
|
||||
@@ -39,76 +54,36 @@ export default function DomainsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||
Домены из каталога для BGP-фильтрации
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "domain",
|
||||
label: "Домен",
|
||||
render: (d) => <span className="font-medium">{d.domain}</span>,
|
||||
},
|
||||
{
|
||||
key: "resolvedIp",
|
||||
label: "Resolved IP",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
countLabel={known ? `${filtered.length} доменов` : "Количество: —"}
|
||||
/>
|
||||
{known || loading ? <DomainsDataGrid
|
||||
domains={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт доменов"
|
||||
description="Загрузите CSV или JSON со списком доменов"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+729
-807
File diff suppressed because it is too large.
Load diff
+1348
-579
File diff suppressed because it is too large.
Load diff
+716
-765
File diff suppressed because it is too large.
Load diff
@@ -1,35 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { IpRangesDataGrid } from "@/components/data-grids/ip-ranges-data-grid"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { ipRanges as mockIpRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon, LoaderCircleIcon } from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon } from "lucide-react"
|
||||
import { useEvoCatalogView, EvoBgpCatalogStatus, EvoBgpCatalogUnavailable } from "@/components/settings/evobgp-catalog-status"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
const { mode } = useDataSource()
|
||||
const { enabled, snapshot, loading, error } = useEvoBGP()
|
||||
const { mode, snapshot, loading, known } = useEvoCatalogView()
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
/** При включённом EvoBGP в live локальные моки не показываем — только каталог API (или пусто при загрузке/ошибке). */
|
||||
const useEvoCatalog = mode === "live" && enabled
|
||||
const useEvoCatalog = mode === "live"
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!useEvoCatalog) return mockIpRanges
|
||||
if (mode === "mock") return mockIpRanges
|
||||
if (loading && !snapshot) return []
|
||||
return snapshot?.ipRanges ?? []
|
||||
}, [useEvoCatalog, loading, snapshot])
|
||||
}, [mode, loading, snapshot])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return rows
|
||||
const q = search.toLowerCase()
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.cidr.toLowerCase().includes(q) ||
|
||||
r.asn.toLowerCase().includes(q) ||
|
||||
r.country.toLowerCase().includes(q) ||
|
||||
r.filter.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
actions={mode === "live" ? null : <>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />Импорт
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||
</>
|
||||
@@ -40,74 +55,36 @@ export default function IpRangesPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||
CIDR-блоки из каталога для сетевых фильтров
|
||||
</p>
|
||||
{useEvoCatalog && (
|
||||
<p className={cn(
|
||||
"text-xs mt-2 flex items-center gap-1.5",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{loading && <LoaderCircleIcon className="size-3.5 animate-spin shrink-0" />}
|
||||
{error
|
||||
? `EvoBGP: ${error}`
|
||||
: snapshot?.fetchedAt
|
||||
? `Источник: EvoBGP, обновлено ${new Date(snapshot.fetchedAt).toLocaleString("ru-RU")}`
|
||||
: loading ? "Загрузка EvoBGP…" : "EvoBGP"}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3"><EvoBgpCatalogStatus /></div>
|
||||
</div>
|
||||
<DataTable
|
||||
data={rows}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "cidr",
|
||||
label: "CIDR",
|
||||
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Страна",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
countLabel={known ? `${filtered.length} диапазонов` : "Количество: —"}
|
||||
/>
|
||||
{known || loading ? <IpRangesDataGrid
|
||||
ipRanges={filtered}
|
||||
isLoading={useEvoCatalog && loading && !snapshot}
|
||||
pagination={useEvoCatalog}
|
||||
emptyDescription={search ? "Измените поисковый запрос" : useEvoCatalog ? "В подтверждённом каталоге нет записей этого типа" : undefined}
|
||||
/> : <EvoBgpCatalogUnavailable />}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт IP-диапазонов"
|
||||
description="Загрузите CSV или JSON со списком CIDR-блоков"
|
||||
accept=".csv,.json,text/csv,application/json"
|
||||
onImport={async (files) => {
|
||||
toast.info(`Выбран файл: ${files[0]?.name ?? "—"}`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+25
-11
@@ -1,21 +1,35 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||
import { DataSourceProvider } from "@/lib/data-source"
|
||||
import { EvoBGPProvider } from "@/lib/evobgp-context"
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const SKIP_TO_CONTENT_CLASS =
|
||||
"bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2"
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
<AuthGuard>
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset tabIndex={-1} id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function MainLoading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||
export default function LogsPage() { return <LogsWorkspace /> }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Suspense } from "react"
|
||||
import { McpWorkspace } from "@/components/mcp/mcp-workspace"
|
||||
|
||||
export default function McpPage() {
|
||||
return <Suspense fallback={<p role="status" className="p-6">Загрузка MCP…</p>}><McpWorkspace /></Suspense>
|
||||
}
|
||||
+1658
-470
File diff suppressed because it is too large.
Load diff
+561
-642
File diff suppressed because it is too large.
Load diff
+342
-481
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,11 @@
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
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"
|
||||
@@ -16,23 +15,23 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
function formatPublishedAt(value: string | null, isProdBuild: boolean): string {
|
||||
if (value) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
return "Дата манифеста: " + new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value))
|
||||
}
|
||||
return isProdBuild ? "Прод-сборка" : "Локальная сборка"
|
||||
return isProdBuild ? "Дата манифеста не указана" : "Локальная сборка"
|
||||
}
|
||||
|
||||
export default function ReleasesPage() {
|
||||
const manifest = readReleaseManifest()
|
||||
const version = getAppVersion()
|
||||
const isProdBuild = !version.endsWith("-dev")
|
||||
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
|
||||
const commits = getReleaseCommitPreview(manifest.commits)
|
||||
const releaseUrl = getReleaseUrl() || manifest?.releaseUrl || null
|
||||
const commits = getReleaseCommitPreview(manifest?.commits ?? [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex min-h-0 flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
|
||||
actions={
|
||||
@@ -40,62 +39,60 @@ export default function ReleasesPage() {
|
||||
<Link
|
||||
href={releaseUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Открыть в Gitea
|
||||
<ExternalLinkIcon className="size-4" aria-hidden />
|
||||
Открыть релиз
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="max-w-[960px] mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Текущая версия</CardTitle>
|
||||
<CardDescription>Сборка MikrotikManager, опубликованная через CI/CD.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<OpsPanel
|
||||
title="Текущая версия"
|
||||
description={isProdBuild ? "Версия запущенной сборки MikrotikManager." : "Локальная разработка. Публикация релиза не подтверждена."}
|
||||
contentClassName="flex flex-col gap-3 px-5 pb-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
|
||||
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt, isProdBuild)}</span>
|
||||
<span className="text-2xl font-semibold tracking-tight break-all">{formatAppVersionLabel(version)}</span>
|
||||
<span className="text-sm text-muted-foreground">{isProdBuild ? formatPublishedAt(manifest?.version === version ? manifest.publishedAt : null, true) : "Локальная сборка"}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Сводка коммитов</CardTitle>
|
||||
<CardDescription>
|
||||
{commits.length > 0
|
||||
? `Последние ${RELEASE_COMMIT_PREVIEW_LIMIT} коммитов в сборке ${formatAppVersionLabel(manifest.version)}.`
|
||||
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!manifest ? <p role="alert" className="text-sm text-destructive">Метаданные релиза недоступны или повреждены. Дата, ссылка и список коммитов не подтверждены.</p> : manifest.version !== version && isProdBuild ? <p role="status" className="text-sm text-muted-foreground">Манифест относится к {formatAppVersionLabel(manifest.version)}; версия запущенной сборки отличается.</p> : null}
|
||||
|
||||
<OpsPanel
|
||||
title="Сводка коммитов"
|
||||
description={
|
||||
commits.length > 0
|
||||
? `Коммиты манифеста ${formatAppVersionLabel(manifest!.version)}: ${commits.length}.`
|
||||
: manifest ? "В манифесте нет коммитов. При публикации список формируется CI." : "Список коммитов не подтверждён."
|
||||
}
|
||||
contentClassName="px-5 pb-5"
|
||||
>
|
||||
{commits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
|
||||
<p className="text-sm text-muted-foreground">{manifest ? "Коммитов для отображения пока нет." : "Нет подтверждённых метаданных."}</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
<ul className="flex flex-col gap-3">
|
||||
{commits.map((commit) => (
|
||||
<li key={commit.sha} className="rounded-md border px-3 py-2">
|
||||
<li key={commit.sha} className="rounded-md border px-3 py-2 break-words">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase">{commit.type}</span>
|
||||
<span className="font-mono">{commit.shortSha}</span>
|
||||
<span>{commit.author}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{formatCommitSubject(commit.subject)}</p>
|
||||
<p className="mt-1 text-sm break-words">{formatCommitSubject(commit.subject)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+348
-760
File diff suppressed because it is too large.
Load diff
+434
-546
File diff suppressed because it is too large.
Load diff
+64
-1738
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,531 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import {
|
||||
ActivityIcon,
|
||||
DatabaseIcon,
|
||||
GaugeIcon,
|
||||
ServerIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { SegmentedControl } from "@/components/form-kit"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
||||
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
|
||||
import { SliceChips } from "@/components/statistics/slice-chips"
|
||||
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
|
||||
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
|
||||
import {
|
||||
StatisticsBreakdownDataGrid,
|
||||
type StatisticsSliceKind,
|
||||
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { StatisticsFilters } from "@/components/statistics/statistics-filters"
|
||||
import {
|
||||
isStatisticsPivotDim,
|
||||
isStatisticsSliceKind,
|
||||
STATISTICS_DIMS,
|
||||
} from "@/lib/statistics-dims"
|
||||
import { statisticsContextError, STATISTICS_CONTEXT_KEYS } from "@/lib/statistics-context"
|
||||
import { readStatisticsRange } from "@/lib/statistics-range"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||
import {
|
||||
getStatistics,
|
||||
getStatisticsPivot,
|
||||
STATISTICS_UNBOUND_USER_ID,
|
||||
type StatisticsDto,
|
||||
type StatisticsPivotDto,
|
||||
type StatisticsQuery,
|
||||
} from "@/shared/api/statistics"
|
||||
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
|
||||
|
||||
/**
|
||||
* BI-куб трафика: критерий → остальные разрезы + pivot.
|
||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
|
||||
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
|
||||
const EMPTY: StatisticsDto = {
|
||||
from: "",
|
||||
to: "",
|
||||
grain: "day",
|
||||
kpis: {
|
||||
bytes: 0,
|
||||
packets: 0,
|
||||
avgBps: 0,
|
||||
users: 0,
|
||||
servers: 0,
|
||||
ifaces: 0,
|
||||
topCountry: "",
|
||||
topService: "",
|
||||
},
|
||||
series: [],
|
||||
users: [],
|
||||
servers: [],
|
||||
interfaces: [],
|
||||
countries: [],
|
||||
services: [],
|
||||
asns: [],
|
||||
}
|
||||
|
||||
const EMPTY_PIVOT: StatisticsPivotDto = {
|
||||
rowDim: "country",
|
||||
colDim: "service",
|
||||
metric: "bytes",
|
||||
columns: [],
|
||||
rows: [],
|
||||
otherBytes: 0,
|
||||
}
|
||||
|
||||
interface CubeSlices {
|
||||
country?: string
|
||||
service?: string
|
||||
asn?: string
|
||||
serverId?: string
|
||||
userId?: string
|
||||
iface?: string
|
||||
}
|
||||
|
||||
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||
|
||||
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||
const t = sp.get("dim") ?? sp.get("tab")
|
||||
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||
}
|
||||
|
||||
function readView(sp: URLSearchParams): "explore" | "pivot" {
|
||||
return sp.get("view") === "pivot" ? "pivot" : "explore"
|
||||
}
|
||||
|
||||
function readPlanes(sp: URLSearchParams): "unique" | "all" {
|
||||
return sp.get("planes") === "all" ? "all" : "unique"
|
||||
}
|
||||
|
||||
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
|
||||
const v = sp.get(key)
|
||||
return v && isStatisticsPivotDim(v) ? v : fallback
|
||||
}
|
||||
|
||||
function readSlices(sp: URLSearchParams): CubeSlices {
|
||||
const next: CubeSlices = {}
|
||||
for (const key of SLICE_KEYS) {
|
||||
const v = sp.get(key)?.trim()
|
||||
if (v) next[key] = v
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||
return {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
|
||||
userId: slices.userId,
|
||||
iface: slices.iface,
|
||||
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
||||
service: slices.service,
|
||||
asn: Number.isFinite(asn) ? asn : undefined,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
||||
if (kind === "users") return slices.userId
|
||||
if (kind === "servers") return slices.serverId
|
||||
if (kind === "countries") return slices.country
|
||||
if (kind === "services") return slices.service
|
||||
if (kind === "asns") return slices.asn
|
||||
if (kind === "interfaces" && slices.serverId && slices.iface) {
|
||||
return `${slices.serverId}:${slices.iface}`
|
||||
}
|
||||
if (kind === "interfaces") return slices.iface
|
||||
return undefined
|
||||
}
|
||||
|
||||
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
|
||||
if (kind === "users") return data.users
|
||||
if (kind === "servers") return data.servers
|
||||
if (kind === "interfaces") return data.interfaces
|
||||
if (kind === "countries") return data.countries
|
||||
if (kind === "services") return data.services
|
||||
return data.asns
|
||||
}
|
||||
|
||||
function hasAnySlice(slices: CubeSlices): boolean {
|
||||
return SLICE_KEYS.some((k) => Boolean(slices[k]))
|
||||
}
|
||||
|
||||
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
|
||||
const hidden = new Set<StatisticsSliceKind>()
|
||||
if (slices.userId) hidden.add("users")
|
||||
if (slices.serverId) hidden.add("servers")
|
||||
if (slices.iface) hidden.add("interfaces")
|
||||
if (slices.country) hidden.add("countries")
|
||||
if (slices.service) hidden.add("services")
|
||||
if (slices.asn) hidden.add("asns")
|
||||
return hidden
|
||||
}
|
||||
|
||||
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
|
||||
const next: CubeSlices = { ...slices }
|
||||
if (kind === "users") {
|
||||
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
|
||||
if (next.userId === rowId) delete next.userId
|
||||
else next.userId = rowId
|
||||
} else if (kind === "servers") {
|
||||
if (next.serverId === rowId) delete next.serverId
|
||||
else next.serverId = rowId
|
||||
} else if (kind === "countries") {
|
||||
if (next.country === rowId) delete next.country
|
||||
else next.country = rowId
|
||||
} else if (kind === "services") {
|
||||
if (next.service === rowId) delete next.service
|
||||
else next.service = rowId
|
||||
} else if (kind === "asns") {
|
||||
if (next.asn === rowId) delete next.asn
|
||||
else next.asn = rowId
|
||||
} else {
|
||||
const colon = rowId.indexOf(":")
|
||||
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
|
||||
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
|
||||
if (next.iface === iface && next.serverId === sid) {
|
||||
delete next.iface
|
||||
delete next.serverId
|
||||
} else {
|
||||
next.iface = iface
|
||||
if (sid) next.serverId = sid
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
|
||||
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
|
||||
return applyDimValue(slices, kind, id)
|
||||
}
|
||||
|
||||
function chipList(slices: CubeSlices): { key: string; label: string }[] {
|
||||
const chips: { key: string; label: string }[] = []
|
||||
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
|
||||
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
|
||||
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
|
||||
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
|
||||
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
|
||||
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
|
||||
return chips
|
||||
}
|
||||
|
||||
function StatisticsPageInner() {
|
||||
const searchParams = useSearchParams()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const { range, error: rangeError } = useMemo(() => readStatisticsRange(searchParams, rangeForPreset("7d")), [searchParams])
|
||||
const paramsError = useMemo(() => statisticsContextError(searchParams), [searchParams])
|
||||
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||
|
||||
const [retry, setRetry] = useState(0)
|
||||
const requestKey = JSON.stringify({ backendUrl, isLive, range, slices, view, pivotRow, pivotCol, planes, retry, rangeError, paramsError })
|
||||
const [result, setResult] = useState<{
|
||||
key: string; data: StatisticsDto; pivot: StatisticsPivotDto; error: string | null
|
||||
} | null>(null)
|
||||
const current = result?.key === requestKey ? result : null
|
||||
const loading = isLive && !rangeError && !paramsError && current === null
|
||||
const error = rangeError ?? paramsError ?? current?.error ?? null
|
||||
const dataReady = isLive && current !== null && !error
|
||||
const pivot = dataReady ? current.pivot : EMPTY_PIVOT
|
||||
|
||||
const updateParams = useCallback(
|
||||
(patch: Record<string, string | undefined>) => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) sp.set(k, v)
|
||||
else sp.delete(k)
|
||||
}
|
||||
const qs = sp.toString()
|
||||
if (qs === searchParams.toString()) return
|
||||
window.history.pushState(null, "", (qs ? `/statistics?${qs}` : "/statistics") + window.location.hash)
|
||||
},
|
||||
[searchParams],
|
||||
)
|
||||
|
||||
const setRange = useCallback(
|
||||
(next: DateRangeYmd) => {
|
||||
updateParams({ from: next.from, to: next.to })
|
||||
},
|
||||
[updateParams],
|
||||
)
|
||||
|
||||
const setSlices = useCallback(
|
||||
(next: CubeSlices) => {
|
||||
updateParams({
|
||||
country: next.country,
|
||||
service: next.service,
|
||||
asn: next.asn,
|
||||
serverId: next.serverId,
|
||||
userId: next.userId,
|
||||
iface: next.iface,
|
||||
})
|
||||
},
|
||||
[updateParams],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsHydrated || !isLive || rangeError || paramsError) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const query = toQuery(range, slices, planes)
|
||||
const dto = await getStatistics(backendUrl, query)
|
||||
if (cancelled) return
|
||||
let matrix = EMPTY_PIVOT
|
||||
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||
matrix = await getStatisticsPivot(backendUrl, {
|
||||
...query,
|
||||
row: pivotRow,
|
||||
col: pivotCol,
|
||||
metric: "bytes",
|
||||
})
|
||||
}
|
||||
if (!cancelled) setResult({ key: requestKey, data: dto, pivot: matrix, error: null })
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) {
|
||||
setResult({ key: requestKey, data: EMPTY, pivot: EMPTY_PIVOT, error: e instanceof Error ? e.message : "Не удалось загрузить статистику" })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes, requestKey, rangeError, paramsError])
|
||||
|
||||
const viewData = dataReady ? current.data : EMPTY
|
||||
const sliced = hasAnySlice(slices)
|
||||
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||
|
||||
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
|
||||
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
|
||||
if (kind === "interfaces" && row.label.includes("· дубль")) return
|
||||
setSlices(applyDimValue(slices, kind, row.id))
|
||||
}
|
||||
|
||||
function handlePivotCell(rowId: string, colId: string) {
|
||||
if (rowId === "__other__" || colId === "__other__") return
|
||||
let next = applyPivotDim(slices, pivotRow, rowId)
|
||||
next = applyPivotDim(next, pivotCol, colId)
|
||||
updateParams({
|
||||
country: next.country,
|
||||
service: next.service,
|
||||
asn: next.asn,
|
||||
serverId: next.serverId,
|
||||
userId: next.userId,
|
||||
iface: next.iface,
|
||||
view: "explore",
|
||||
})
|
||||
}
|
||||
|
||||
const kpis = viewData.kpis
|
||||
const chips = chipList(slices)
|
||||
const countLabel =
|
||||
view === "pivot"
|
||||
? `${pivot.rows.length} × ${pivot.columns.length}`
|
||||
: sliced
|
||||
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
|
||||
: `${rowsForKind(viewData, dim).length} строк`
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||
actions={rangeError ? <Button type="button" variant="outline" onClick={() => setRange(rangeForPreset("7d"))}>Выбрать последние 7 дней</Button> : <PeriodSelector range={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{!isLive ? (
|
||||
<Alert>
|
||||
<AlertTitle>Живые данные выключены</AlertTitle>
|
||||
<AlertDescription>
|
||||
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>{rangeError ? "Некорректный период" : paramsError ? "Параметры отчёта требуют внимания" : "Ошибка загрузки"}</AlertTitle>
|
||||
<AlertDescription>{error}{!rangeError && !paramsError && <Button type="button" variant="outline" size="sm" onClick={() => setRetry(n => n + 1)}>Повторить загрузку</Button>}{paramsError && <Button type="button" variant="outline" size="sm" onClick={() => updateParams(Object.fromEntries(STATISTICS_CONTEXT_KEYS.map(key => [key, undefined])))}>Сбросить параметры отчёта</Button>}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!slices.serverId && dataReady && !emptyCube ? (
|
||||
<Alert>
|
||||
<AlertTitle>Уникальный объём</AlertTitle>
|
||||
<AlertDescription>
|
||||
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{(dataReady || loading) && <KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
items={[
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Объём",
|
||||
value: formatBytes(kpis.bytes),
|
||||
hint: "GRE/WG клиентов, без hops",
|
||||
icon: <DatabaseIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "packets",
|
||||
label: "Пакеты",
|
||||
value: kpis.packets.toLocaleString("ru-RU"),
|
||||
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
|
||||
icon: <ActivityIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "bps",
|
||||
label: "Средний bitrate",
|
||||
value: fmtBps(kpis.avgBps),
|
||||
icon: <GaugeIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
value: String(kpis.users),
|
||||
icon: <UsersIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы",
|
||||
value: String(kpis.servers),
|
||||
hint: slices.serverId
|
||||
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
|
||||
: planes === "all"
|
||||
? "WAN и дубли в списке"
|
||||
: "без WAN и overlay",
|
||||
icon: <ServerIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>}
|
||||
|
||||
{dataReady && <StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />}
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
leading={
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={(next) => updateParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
||||
options={[
|
||||
{ value: "explore", label: "Разрез" },
|
||||
{ value: "pivot", label: "Сводка" },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={planes}
|
||||
onChange={(next) => updateParams({ planes: next === "all" ? "all" : undefined })}
|
||||
options={[
|
||||
{ value: "unique", label: "Уникальный" },
|
||||
{ value: "all", label: "Все плоскости" },
|
||||
]}
|
||||
/>
|
||||
{view === "explore" && !sliced ? (
|
||||
<DimensionSelect
|
||||
label="Критерий"
|
||||
value={dim}
|
||||
onChange={(next) => updateParams({ dim: next, tab: undefined })}
|
||||
/>
|
||||
) : null}
|
||||
{view === "pivot" ? (
|
||||
<>
|
||||
<PivotDimSelect
|
||||
label="Строки"
|
||||
value={pivotRow}
|
||||
exclude={pivotCol}
|
||||
onChange={(next) => updateParams({ pivotRow: next })}
|
||||
/>
|
||||
<PivotDimSelect
|
||||
label="Колонки"
|
||||
value={pivotCol}
|
||||
exclude={pivotRow}
|
||||
onChange={(next) => updateParams({ pivotCol: next })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
filterControl={<StatisticsFilters key={JSON.stringify(slices)} slices={slices} onApply={setSlices} />}
|
||||
countLabel={dataReady ? countLabel : undefined}
|
||||
/>
|
||||
<SliceChips
|
||||
chips={chips}
|
||||
onRemove={(key) => {
|
||||
const next = { ...slices }
|
||||
delete next[key as keyof CubeSlices]
|
||||
setSlices(next)
|
||||
}}
|
||||
/>
|
||||
{isLive && !dataReady ? (
|
||||
<div role="status" className="p-6 text-sm text-muted-foreground">{loading ? "Загрузка статистики…" : rangeError || paramsError ? "Исправьте параметры отчёта для загрузки статистики." : "Статистика недоступна. Повторите загрузку."}</div>
|
||||
) : emptyCube ? (
|
||||
<EmptyState
|
||||
title="Нет данных куба"
|
||||
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||
/>
|
||||
) : view === "pivot" ? (
|
||||
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
|
||||
) : sliced ? (
|
||||
<BreakdownDashboard
|
||||
data={viewData}
|
||||
hidden={hiddenKinds(slices)}
|
||||
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={loading}
|
||||
/>
|
||||
) : (
|
||||
<StatisticsBreakdownDataGrid
|
||||
rows={rowsForKind(viewData, dim)}
|
||||
kind={dim}
|
||||
selectedId={selectedIdForKind(dim, slices)}
|
||||
onRowClick={(row) => handleRowClick(dim, row)}
|
||||
isLoading={loading}
|
||||
/>
|
||||
)}
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<StatisticsPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
+120
-428
@@ -1,13 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { useState, useRef, useEffect, Suspense } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {useSearchParams} from "next/navigation"
|
||||
import {can} from "@/lib/auth"
|
||||
import {terminalServersSchema,readTerminalOutput,readTerminalUrl,patchTerminalUrl} from "@/lib/terminal-response"
|
||||
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -157,229 +174,72 @@ function mockResolve(raw: string, serverName: string): string {
|
||||
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||
}
|
||||
|
||||
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
||||
|
||||
function mockMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
" MMM MMM KKK TTTTTTTTTTT KKK",
|
||||
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
||||
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
"[?] Gives the list of available commands",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"[..] Move up one level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function liveMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
` Connected to ${server.name} (${server.host})`,
|
||||
"",
|
||||
"[?] Gives the list of available commands — type help or ?",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// ─── terminal component ───────────────────────────────────────────────────────
|
||||
|
||||
function Terminal({
|
||||
server,
|
||||
isLive,
|
||||
backendUrl,
|
||||
}: {
|
||||
server: TermServer
|
||||
isLive: boolean
|
||||
backendUrl: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<TermLine[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [_histIdx, setHistIdx] = useState(-1)
|
||||
const [_idCtr, setIdCtr] = useState(0)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const nextId = useCallback((): number => {
|
||||
let id = 0
|
||||
setIdCtr(prev => { id = prev + 1; return prev + 1 })
|
||||
return id
|
||||
}, [])
|
||||
|
||||
// Initialize MOTD on mount / server change
|
||||
useEffect(() => {
|
||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
||||
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
|
||||
setLines(init)
|
||||
setIdCtr(init.length)
|
||||
setInput("")
|
||||
setHistory([])
|
||||
setHistIdx(-1)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.uid, isLive])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
||||
}, [lines])
|
||||
|
||||
const prompt = `[admin@${server.identityName ?? server.name}] > `
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (executing) return
|
||||
const cmd = input.trim()
|
||||
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
|
||||
setInput("")
|
||||
setHistIdx(-1)
|
||||
|
||||
if (!cmd) return
|
||||
|
||||
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
|
||||
|
||||
// local always-available commands
|
||||
const lower = cmd.toLowerCase()
|
||||
if (lower === "clear" || lower === "/clear") { setLines([]); return }
|
||||
if (lower === "quit" || lower === "exit") {
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
|
||||
return
|
||||
}
|
||||
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
|
||||
)
|
||||
} catch (err) {
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
} else {
|
||||
// mock mode
|
||||
const result = mockResolve(cmd, server.name)
|
||||
if (result === "__CLEAR__") { setLines([]); return }
|
||||
if (result) {
|
||||
result.split("\n").forEach(line =>
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); void submit()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setHistIdx(i => {
|
||||
const next = Math.min(i + 1, history.length - 1)
|
||||
if (history[next] !== undefined) setInput(history[next])
|
||||
return next
|
||||
})
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setHistIdx(i => {
|
||||
const next = i - 1
|
||||
if (next < 0) { setInput(""); return -1 }
|
||||
if (history[next] !== undefined) setInput(history[next])
|
||||
return next
|
||||
})
|
||||
} else if (e.key === "l" && e.ctrlKey) {
|
||||
e.preventDefault(); setLines([])
|
||||
}
|
||||
function Terminal({server,isLive,backendUrl,canExecute,blockedReason}:{server:TermServer;isLive:boolean;backendUrl:string;canExecute:boolean;blockedReason?:string}) {
|
||||
const [lines,setLines]=useState<TermLine[]>([{id:0,kind:"info",text:isLive?"REST: каждая команда — отдельный запрос. Результат появится после ответа API.":"Демо: команды возвращают синтетические данные."}])
|
||||
const [input,setInput]=useState(""),[history,setHistory]=useState<string[]>([]),[histIdx,setHistIdx]=useState(-1),[executing,setExecuting]=useState(false),[error,setError]=useState<string|null>(null),[completed,setCompleted]=useState<string|null>(null)
|
||||
const nextId=useRef(1),lock=useRef(false),reader=useRef<{alive:boolean;controller:AbortController|null}>({alive:true,controller:null})
|
||||
const inputRef=useRef<HTMLInputElement>(null),scrollRef=useRef<HTMLDivElement>(null),followOutput=useRef(true),pendingFocus=useRef(false)
|
||||
useEffect(()=>{const state=reader.current;state.alive=true;return()=>{state.alive=false;state.controller?.abort()}},[])
|
||||
useEffect(()=>{if(followOutput.current)scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:"auto"})},[lines])
|
||||
// Focus only after React has committed the re-enabled input.
|
||||
useEffect(()=>{
|
||||
if(executing||!pendingFocus.current)return
|
||||
pendingFocus.current=false
|
||||
if(reader.current.alive&&!inputRef.current?.disabled)inputRef.current?.focus()
|
||||
},[executing,lines])
|
||||
function append(kind:TermLine["kind"],text:string) {
|
||||
const added=text.split("\n").map(text=>({id:nextId.current++,kind,text}))
|
||||
setLines(previous=>{const merged=[...previous.filter(line=>line.id!==-1),...added];return merged.length>1000||previous.some(line=>line.id===-1)?[{id:-1,kind:"info",text:"Показаны последние 1000 строк; ранний вывод удалён."},...merged.slice(-1000)]:merged})
|
||||
}
|
||||
async function submit(){
|
||||
const command=input.trim();if(lock.current||!command||!canExecute||!server.enabled||blockedReason)return
|
||||
if(command.toLowerCase()==="clear"||command.toLowerCase()==="/clear"){setLines([]);setInput("");return}
|
||||
lock.current=true;setExecuting(true);setError(null);setCompleted(null);setHistIdx(-1)
|
||||
append("prompt",server.name+" > "+command)
|
||||
setHistory(previous=>[command,...previous.filter(item=>item!==command)].slice(0,100))
|
||||
const controller=new AbortController();reader.current.controller=controller
|
||||
let timedOut=false;const timer=window.setTimeout(()=>{timedOut=true;controller.abort()},60000)
|
||||
try {
|
||||
const output=isLive?readTerminalOutput(await requestJson<unknown>(backendUrl,"/api/servers/"+server.backendId+"/exec",{method:"POST",body:JSON.stringify({command}),signal:controller.signal})):mockResolve(command,server.name)
|
||||
if(!reader.current.alive)return
|
||||
if(output==="__CLEAR__")setLines([]);else append("output",output||"Пустой вывод.")
|
||||
setInput("");setCompleted((isLive?"Ответ API получен":"Результат демо")+" · "+new Date().toLocaleTimeString("ru-RU"));followOutput.current=true
|
||||
} catch(err) {
|
||||
if(!reader.current.alive)return
|
||||
const message=timedOut?"Время ожидания истекло. Результат запроса не подтверждён.":err instanceof Error?err.message:"Не удалось получить вывод команды."
|
||||
append("error",message);setError(message)
|
||||
} finally {
|
||||
window.clearTimeout(timer);lock.current=false
|
||||
if(reader.current.alive){pendingFocus.current=true;setExecuting(false);reader.current.controller=null}
|
||||
}
|
||||
|
||||
const lineColor = (kind: TermLine["kind"]) => {
|
||||
if (kind === "error") return "text-red-400"
|
||||
if (kind === "prompt") return "text-emerald-400"
|
||||
if (kind === "info") return "text-sky-400"
|
||||
return "text-[#c9d1d9]"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* title bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setLines([]) }}
|
||||
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
||||
title="Очистить"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* output */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* executing indicator */}
|
||||
{executing && (
|
||||
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
<span>executing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* input line */}
|
||||
{!executing && (
|
||||
<div className="flex items-center gap-0">
|
||||
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function handleKey(e:React.KeyboardEvent<HTMLInputElement>){
|
||||
if(e.key==="Enter"){e.preventDefault();void submit()}
|
||||
else if(e.key==="ArrowUp"){e.preventDefault();const index=Math.min(histIdx+1,history.length-1);setHistIdx(index);if(history[index]!==undefined)setInput(history[index])}
|
||||
else if(e.key==="ArrowDown"){e.preventDefault();const index=histIdx-1;setHistIdx(Math.max(-1,index));setInput(index<0?"":history[index]??"")}
|
||||
else if(e.key.toLowerCase()==="l"&&e.ctrlKey){e.preventDefault();setLines([])}
|
||||
}
|
||||
const blocked=blockedReason??(!server.enabled?"Устройство отключено в каталоге.":!canExecute?"Для выполнения требуется mm:servers:write.":null)
|
||||
return <Frame spacing="sm" className="h-full min-h-0 min-w-0">
|
||||
<FramePanel className="flex flex-1 flex-col min-h-0 min-w-0 p-0">
|
||||
<FrameHeader className="border-b shrink-0 flex-row items-start justify-between gap-2">
|
||||
<div className="min-w-0"><FrameTitle className="[overflow-wrap:anywhere]">{server.name}</FrameTitle><FrameDescription className="[overflow-wrap:anywhere]">{server.host} · {isLive?"RouterOS REST":"Демо"} · версия {server.rosVersion??"неизвестна"}</FrameDescription></div>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Очистить вывод терминала" onClick={()=>setLines([])}><TrashIcon aria-hidden="true" className="size-4"/></Button>
|
||||
</FrameHeader>
|
||||
<div ref={scrollRef} role="region" aria-label="Вывод терминала" tabIndex={0} onScroll={e=>{const el=e.currentTarget;followOutput.current=el.scrollHeight-el.scrollTop-el.clientHeight<32}} className="flex-1 min-h-0 min-w-0 overflow-auto p-3 font-mono text-xs focus-visible:outline-2 focus-visible:outline-ring">
|
||||
{lines.length?lines.map(line=><div key={line.id} className={cn("whitespace-pre-wrap [overflow-wrap:anywhere] leading-5",line.kind==="error"?"text-destructive":line.kind==="prompt"?"text-primary":line.kind==="info"?"text-muted-foreground":"text-foreground")}>{line.text}</div>):<p className="text-muted-foreground">Вывод очищен. Следующая команда добавит новый результат.</p>}
|
||||
</div>
|
||||
<FrameFooter className="shrink-0 border-t flex flex-col items-stretch gap-2">
|
||||
<p role="status" className="text-xs text-muted-foreground">{executing?"Выполняем команду; ожидаем ответ…":completed??"Команда ещё не выполнена."}</p>
|
||||
{error&&<p role="alert" className="text-xs text-destructive [overflow-wrap:anywhere]">{error} Команда сохранена для повторного выполнения.</p>}
|
||||
{blocked&&<p role="status" className="text-xs text-muted-foreground">{blocked}</p>}
|
||||
<label className="text-xs" htmlFor="terminal-command">Команда · {server.name}</label>
|
||||
<div className="flex gap-2 min-w-0"><Input ref={inputRef} id="terminal-command" value={input} maxLength={1024} onChange={e=>setInput(e.target.value)} onKeyDown={handleKey} disabled={executing||!!blocked} className="flex-1 min-w-0 font-mono text-xs" autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}/><Button type="button" size="sm" disabled={executing||!!blocked||!input.trim()} onClick={()=>void submit()}>{executing?"Ожидание…":"Выполнить"}</Button></div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 text-xs text-muted-foreground"><select aria-label="Быстрая команда" value="" disabled={executing||!!blocked} className="min-w-0 rounded-md border bg-background px-2 py-1" onChange={e=>{setInput(e.target.value);setHistIdx(-1);inputRef.current?.focus()}}><option value="">Подставить команду…</option>{QUICK_CMDS.map(item=><option key={item.cmd} value={item.cmd}>{item.label}</option>)}</select><p>↑↓ — история · Ctrl+L — очистить · до 1024 символов</p></div>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
}
|
||||
|
||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||
@@ -407,215 +267,47 @@ function mockServersToTermServers(): TermServer[] {
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
country: s.country,
|
||||
status: s.status as TermServer["status"],
|
||||
status: s.status==="unknown"?null:s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os ?? null,
|
||||
identityName: s.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Backend ServerRead shape (only fields we need) */
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
status: "online" | "offline" | null
|
||||
enabled: boolean
|
||||
os: string | null
|
||||
identityName: string | null
|
||||
|
||||
export default function TerminalPage(){
|
||||
return <Suspense fallback={<p role="status" className="p-4">Загружаем терминал…</p>}><TerminalRoute/></Suspense>
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
const [serversLoading, setServersLoading] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
// Select first available server by default
|
||||
const [selectedUid, setSelectedUid] = useState<string>("")
|
||||
|
||||
const defaultUid = useMemo(() => {
|
||||
const first = termServers.find(s => s.status !== "offline" && s.enabled)
|
||||
?? termServers[0]
|
||||
return first?.uid ?? ""
|
||||
}, [termServers])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
})
|
||||
}, [defaultUid, selectedUid])
|
||||
|
||||
// Reset selection when switching modes
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setSelectedUid(""))
|
||||
}, [isLive])
|
||||
|
||||
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
||||
|
||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||
|
||||
function injectCommand(cmd: string) {
|
||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||
if (!el) return
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
|
||||
nativeSetter?.call(el, cmd)
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
el.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}>
|
||||
<RefreshCwIcon className="size-4" />Переподключить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
||||
|
||||
{/* ── sidebar ── */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
{/* server picker */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
||||
{isLive && serversLoading && (
|
||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{isLive && !serversLoading && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2.5">
|
||||
Нет доступных серверов
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{termServers.map(s => {
|
||||
const isOffline = s.status === "offline"
|
||||
const isSelected = s.uid === selectedUid
|
||||
return (
|
||||
<button
|
||||
key={s.uid}
|
||||
disabled={isOffline || !s.enabled}
|
||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
||||
"flex items-center gap-2",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block size-1.5 rounded-full shrink-0",
|
||||
s.status === "online" ? "bg-emerald-500" :
|
||||
s.status === "degraded" ? "bg-amber-400" :
|
||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
||||
)} />
|
||||
{s.country && <Flag code={s.country} />}
|
||||
<span className="truncate font-mono">{s.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* quick commands */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Быстрые команды
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* hints */}
|
||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── terminal ── */}
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
server={selected}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
function TerminalRoute(){
|
||||
const {mode,backendUrl,prefsHydrated}=useDataSource(),params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||
function reset(){const href=patchTerminalUrl(new URLSearchParams(params.toString()),null)+window.location.hash;window.history.replaceState(null,"",href)}
|
||||
if(url.error)return <Alert variant="destructive"><AlertTitle>Некорректная ссылка терминала</AlertTitle><AlertDescription>{url.error}<Button type="button" variant="outline" onClick={reset}>Сбросить контекст терминала</Button></AlertDescription></Alert>
|
||||
if(!prefsHydrated)return <p role="status" className="p-4">Загружаем источник…</p>
|
||||
return <TerminalContent key={JSON.stringify([mode,backendUrl])}/>
|
||||
}
|
||||
function TerminalContent(){
|
||||
const {mode,backendUrl}=useDataSource(),isLive=mode==="live",params=useSearchParams(),url=readTerminalUrl(new URLSearchParams(params.toString()))
|
||||
const [liveServers,setLiveServers]=useState<TermServer[]>([]),[loading,setLoading]=useState(false),[error,setError]=useState<string|null>(null),[loaded,setLoaded]=useState(false),[readAt,setReadAt]=useState<string|null>(null),[refresh,setRefresh]=useState(0)
|
||||
const canRead=can("mm:servers:read"),canExecute=!isLive||can("mm:servers:write")
|
||||
useEffect(()=>{
|
||||
if(!isLive||!canRead)return
|
||||
let cancelled=false;const controller=new AbortController()
|
||||
queueMicrotask(()=>{if(cancelled)return;setLoading(true);void requestJson<unknown>(backendUrl,"/api/servers",{signal:controller.signal}).then(raw=>{
|
||||
const rows=terminalServersSchema.parse(raw);if(cancelled)return
|
||||
setLiveServers(rows.map(row=>({uid:String(row.id),backendId:row.id,name:row.name||row.host,host:row.host,country:row.country,status:row.status,enabled:row.enabled,rosVersion:row.os??null,identityName:row.identityName??null})));setLoaded(true);setReadAt(new Date().toLocaleTimeString("ru-RU"));setError(null)
|
||||
}).catch(err=>{if(!cancelled)setError(err instanceof Error?(err.name==="ZodError"?"Некорректный каталог устройств.":err.message):"Не удалось загрузить устройства.")}).finally(()=>{if(!cancelled)setLoading(false)})})
|
||||
return()=>{cancelled=true;controller.abort()}
|
||||
},[isLive,backendUrl,canRead,refresh])
|
||||
const termServers=isLive?liveServers:mockServersToTermServers()
|
||||
const matches=url.server?termServers.filter(server=>server.uid===url.server):url.host?termServers.filter(server=>server.host===url.host):[]
|
||||
const selected=matches.length===1?matches[0]:null
|
||||
const unavailable=(loaded||!isLive)&&(url.server||url.host)&&!selected
|
||||
const items=termServers.map(server=>({id:server.uid,name:server.name,host:server.host,country:server.country||undefined,status:server.status??"unknown" as const,enabled:server.enabled,selectable:true,title:[server.name,server.host].join(" · ")}))
|
||||
function select(id:string){const href=patchTerminalUrl(new URLSearchParams(params.toString()),id||null)+window.location.hash;window.history.pushState(null,"",href)}
|
||||
const blocked=!!error||loading||isLive&&!loaded
|
||||
return <ServerRailLayout items={items} selectedId={selected?.uid??url.server??""} onSelect={select} preserveSelection showAll={false} showCount={false} showType={false} loading={loading} contentClassName="min-h-0 min-w-0 overflow-hidden p-2 md:p-3"
|
||||
header={<PageHeader crumbs={[{label:"Инструменты"},{label:"Терминал"}]} actions={<><ServerRailMobileButton/>{isLive&&<Button type="button" variant="outline" size="sm" disabled={loading||!canRead} onClick={()=>setRefresh(value=>value+1)}><RefreshCwIcon aria-hidden="true" className="size-4"/>Обновить устройства</Button>}</>}/>}
|
||||
banner={<div className="px-3 py-2 space-y-2"><p role="status" className="text-xs text-muted-foreground">{isLive?(readAt?"Каталог · ответ "+readAt+" (время браузера)":"Каталог ещё не подтверждён."):"Демо: команды не отправляются на устройство."} REST выполняет только поддерживаемые диагностические команды.</p>{!canRead&&isLive&&<Alert variant="destructive"><AlertTitle>Нет доступа к каталогу</AlertTitle><AlertDescription>Требуется mm:servers:read.</AlertDescription></Alert>}{error&&<Alert variant="destructive"><AlertTitle>{loaded?"Каталог не обновлён":"Устройства пока неизвестны"}</AlertTitle><AlertDescription>{error} {loaded?"Сохранён предыдущий каталог; выполнение приостановлено до успешного обновления.":"Повторите загрузку устройств."}</AlertDescription></Alert>}{unavailable&&<Alert variant="destructive"><AlertTitle>Устройство терминала недоступно</AlertTitle><AlertDescription>{matches.length>1?"Host неоднозначен: выберите точное устройство.":"Указанный server/host отсутствует в текущем каталоге."}<Button type="button" variant="outline" onClick={()=>select("")}>Сбросить выбор устройства</Button></AlertDescription></Alert>}</div>}>
|
||||
{selected?<Terminal key={JSON.stringify([selected.uid,selected.host])} server={selected} isLive={isLive} backendUrl={backendUrl} canExecute={canExecute} blockedReason={blocked?"Каталог не подтверждён; выполнение приостановлено до успешного обновления.":undefined}/>:<Frame spacing="sm" className="h-full min-h-0"><FramePanel className="h-full flex flex-col gap-2 items-center justify-center p-4 text-center"><FrameTitle>{loading?"Загружаем устройства…":loaded&&!termServers.length?"Устройств пока нет":"Выберите устройство для терминала"}</FrameTitle><FrameDescription>Выбор сохраняется в URL. Команда отправляется только после нажатия «Выполнить».</FrameDescription></FramePanel></Frame>}
|
||||
</ServerRailLayout>
|
||||
}
|
||||
+890
-468
File diff suppressed because it is too large.
Load diff
+754
-988
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
.container { container-type: inline-size; }
|
||||
.header { display: none; }
|
||||
.serverName { flex: 1 1 160px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
|
||||
}
|
||||
.row > * { min-width: 0; overflow-wrap: anywhere; }
|
||||
.name { grid-area: name; }
|
||||
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
|
||||
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
|
||||
.iface { grid-area: iface; }
|
||||
.filter { grid-area: filter; }
|
||||
.rtt { grid-area: rtt; }
|
||||
.loss { grid-area: loss; }
|
||||
.bt { grid-area: bt; }
|
||||
.graph { grid-area: graph; }
|
||||
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||
.actions { grid-area: actions; }
|
||||
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
|
||||
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
|
||||
|
||||
@container (min-width: 1100px) {
|
||||
.header, .row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
|
||||
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
|
||||
}
|
||||
.label, .mobile { display: none; }
|
||||
.toggle, .star { justify-content: center; }
|
||||
.actions button, .star button { min-width: unset; min-height: unset; }
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { UserSheet } from "@/components/users/user-sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
ALL_SECTIONS,
|
||||
INIT_USERS,
|
||||
userInitials,
|
||||
type AppUser,
|
||||
type AppUserForm,
|
||||
type UserServerOption,
|
||||
} from "@/lib/users"
|
||||
import { serverListSchema } from "@mmapp/contracts/servers"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { can } from "@/lib/auth"
|
||||
import {
|
||||
userApiError,
|
||||
createAppUser,
|
||||
getAppUser,
|
||||
createUserBinding,
|
||||
deleteAppUser,
|
||||
deleteUserBinding,
|
||||
listAppUsers,
|
||||
updateAppUser,
|
||||
} from "@/shared/api/users"
|
||||
import { saveUserSteps, userProfileRequest, type UserSaveCheckpoint } from "@/lib/user-save"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
CableIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function toServerOptionsFromMock(): UserServerOption[] {
|
||||
return mockServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status === "unknown" ? null : s.status,
|
||||
}))
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status">Загрузка настроек пользователей…</p>
|
||||
return <UsersContent key={JSON.stringify([mode, backendUrl])} />
|
||||
}
|
||||
|
||||
function UsersContent() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const canRead = can("mm:users:read")
|
||||
const canWrite = can("mm:users:write")
|
||||
const canReadServers = can("mm:servers:read")
|
||||
|
||||
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
|
||||
const [userSearch, setUserSearch] = useState("")
|
||||
const visibleUsers = useMemo(() => {
|
||||
const query = userSearch.trim().toLocaleLowerCase("ru-RU")
|
||||
return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users
|
||||
}, [users, userSearch])
|
||||
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
|
||||
const [loading, setLoading] = useState(isLive && canRead)
|
||||
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
|
||||
const [serversReadAt, setServersReadAt] = useState<number | null>(null)
|
||||
const [usersError, setUsersError] = useState<string | null>(null)
|
||||
const [serversError, setServersError] = useState<string | null>(null)
|
||||
const loadController = useRef<AbortController | null>(null)
|
||||
const loadRevision = useRef(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveBlocked, setSaveBlocked] = useState(false)
|
||||
const [saveResume, setSaveResume] = useState(false)
|
||||
const saveCheckpoint = useRef<UserSaveCheckpoint | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
const mutationLock = useRef(false)
|
||||
const mutationController = useRef<AbortController | null>(null)
|
||||
const alive = useRef(true)
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||
const formReturnFocus = useRef<HTMLElement | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||
useEffect(() => {
|
||||
if (sheetOpen || !formReturnFocus.current) return
|
||||
const label = formReturnFocus.current.getAttribute("aria-label")
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
|
||||
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [sheetOpen])
|
||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive || !canRead) return false
|
||||
loadController.current?.abort()
|
||||
const controller = new AbortController()
|
||||
loadController.current = controller
|
||||
const revision = ++loadRevision.current
|
||||
const current = () => !controller.signal.aborted && revision === loadRevision.current
|
||||
setLoading(true)
|
||||
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
||||
const [list, srvs] = await Promise.allSettled([
|
||||
listAppUsers(backendUrl, { signal }).then(rows => {
|
||||
if (current()) { setUsers(rows); setUsersReadAt(Date.now()); setUsersError(null) }
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setUsersError(userApiError(error))
|
||||
throw error
|
||||
}),
|
||||
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(raw => {
|
||||
const rows = serverListSchema.parse(raw)
|
||||
if (new Set(rows.map(row => row.id)).size !== rows.length || rows.some(row => row.id <= 0)) throw new Error("Некорректный каталог серверов.")
|
||||
if (current()) {
|
||||
setServerOptions(rows.map(s => ({
|
||||
id: String(s.id), name: s.name || s.host, host: s.host, site: s.site, country: s.country, status: s.status,
|
||||
})))
|
||||
setServersReadAt(Date.now()); setServersError(null)
|
||||
}
|
||||
return rows
|
||||
}).catch(error => {
|
||||
if (current()) setServersError(userApiError(error))
|
||||
throw error
|
||||
}) : Promise.resolve(null),
|
||||
])
|
||||
if (!current()) return false
|
||||
setLoading(false)
|
||||
return list.status === "fulfilled" && srvs.status === "fulfilled"
|
||||
}, [isLive, canRead, canReadServers, backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (isLive && canRead) queueMicrotask(() => { if (!cancelled) void loadLive() })
|
||||
return () => { cancelled = true; loadController.current?.abort() }
|
||||
}, [isLive, canRead, loadLive])
|
||||
|
||||
const usersKnown = !isLive || usersReadAt !== null
|
||||
const serversKnown = !isLive || serversReadAt !== null
|
||||
const writeReady = canWrite && (!isLive || (usersKnown && serversKnown && !loading && !usersError && !serversError))
|
||||
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||
const activeCount = users.filter((u) => u.active).length
|
||||
|
||||
const handleSave = async (form: AppUserForm) => {
|
||||
if (!writeReady || mutationLock.current || saveBlocked) return
|
||||
if (!isLive) {
|
||||
if (!editUser) {
|
||||
const id = "u" + Date.now()
|
||||
setUsers(prev => [...prev, { id, ...form, last: "—", avatar: userInitials(form.name), bindings: form.bindings.map((b, i) => ({ ...b, id: "b" + id + "-" + i, userId: id })) }])
|
||||
} else setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||
setSheetOpen(false); toast.success("Профиль сохранён в демо."); return
|
||||
}
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
const init = () => ({ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
const checkpoint = saveCheckpoint.current ?? { user: editUser }
|
||||
saveCheckpoint.current = checkpoint
|
||||
setSaving(true); setSaveError(null); setOperationNotice(null)
|
||||
try {
|
||||
const saved = await saveUserSteps(form, checkpoint, {
|
||||
create: f => createAppUser(backendUrl, userProfileRequest(f), init()),
|
||||
update: (id, f) => updateAppUser(backendUrl, id, userProfileRequest(f), init()),
|
||||
read: id => getAppUser(backendUrl, id, init()),
|
||||
removeBinding: (userId, id) => deleteUserBinding(backendUrl, userId, id, init()),
|
||||
addBinding: (userId, b) => createUserBinding(backendUrl, userId, {
|
||||
serverId: Number(b.serverId), interfaceName: b.interfaceName, interfaceType: b.interfaceType,
|
||||
peerPublicKey: b.peerPublicKey, peerName: b.peerName, comment: b.comment,
|
||||
}, init()),
|
||||
}, current, state => {
|
||||
if (!current()) return
|
||||
setSaveBlocked(!!state.createUncertain)
|
||||
if (state.user) {
|
||||
if (!editUser) setSaveResume(true)
|
||||
const confirmed = state.user
|
||||
setUsers(prev => prev.some(u => u.id === confirmed.id) ? prev.map(u => u.id === confirmed.id ? confirmed : u) : [...prev, confirmed])
|
||||
}
|
||||
})
|
||||
if (!current() || !saved) return
|
||||
setSheetOpen(false); saveCheckpoint.current = null
|
||||
setOperationNotice("Профиль и привязки подтверждены API.")
|
||||
toast.success("Пользователь сохранён.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Профиль и привязки подтверждены API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const detail = userApiError(error)
|
||||
setSaveError(checkpoint.createUncertain
|
||||
? "Результат создания неизвестен. Повтор заблокирован: проверьте список пользователей. " + detail
|
||||
: checkpoint.bindingsNeedRead
|
||||
? "Профиль подтверждён API; привязки сохранены частично. При повторе сначала прочитаем их текущее состояние. " + detail
|
||||
: detail)
|
||||
} finally {
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget || !writeReady || mutationLock.current) return
|
||||
const target = deleteTarget
|
||||
if (!isLive) {
|
||||
setUsers(prev => prev.filter(u => u.id !== target.id)); setDeleteTarget(null); setDeleteError(null)
|
||||
toast.success("Пользователь удалён в демо.")
|
||||
return
|
||||
}
|
||||
mutationLock.current = true
|
||||
const controller = new AbortController()
|
||||
mutationController.current = controller
|
||||
setDeleting(true); setDeleteError(null); setOperationNotice(null)
|
||||
const current = () => alive.current && !controller.signal.aborted
|
||||
try {
|
||||
await deleteAppUser(backendUrl, target.id, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) })
|
||||
if (!current()) return
|
||||
setUsers(prev => prev.filter(u => u.id !== target.id))
|
||||
setDeleteTarget(null)
|
||||
setOperationNotice("Удаление пользователя подтверждено API.")
|
||||
toast.success("Пользователь удалён.")
|
||||
const refreshed = await loadLive()
|
||||
if (current() && !refreshed) setOperationNotice("Удаление пользователя подтверждено API. Обновление каталогов не удалось; повторите чтение.")
|
||||
} catch (error) {
|
||||
if (current()) setDeleteError(userApiError(error))
|
||||
} finally {
|
||||
if (mutationController.current === controller) { mutationController.current = null; mutationLock.current = false }
|
||||
if (alive.current) setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||
actions={
|
||||
<>
|
||||
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
|
||||
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
|
||||
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
||||
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
||||
{!canWrite && <p role="status">Для изменения пользователей требуется mm:users:write.</p>}
|
||||
{isLive && !canReadServers && <p role="status">Каталог серверов недоступен: требуется mm:servers:read. Изменения приостановлены.</p>}
|
||||
{usersError && <div role="alert" aria-label="Пользователи" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{usersKnown ? "Не удалось обновить пользователей. Последний список сохранён." : "Пользователи пока неизвестны."}</p><p>{usersError}</p></div>}
|
||||
{serversError && <div role="alert" aria-label="Серверы пользователей" className="rounded-lg border border-destructive/30 p-3 text-sm"><p>{serversKnown ? "Не удалось обновить серверы. Последний каталог сохранён." : "Каталог серверов пока неизвестен."} Изменения приостановлены.</p><p>{serversError}</p></div>}
|
||||
{isLive && usersReadAt !== null && <p role="status" className="text-xs text-muted-foreground">Пользователи получены: {new Date(usersReadAt).toLocaleTimeString("ru-RU")}{loading ? " · обновляются" : ""}</p>}
|
||||
{isLive && loading && !usersKnown && <p role="status">Загрузка пользователей…</p>}
|
||||
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка пользователей"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Пользователи",
|
||||
value: usersKnown ? users.length : "—",
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активные",
|
||||
value: usersKnown ? activeCount : "—",
|
||||
icon: <UserCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Привязанные интерфейсы",
|
||||
value: usersKnown ? boundCount : "—",
|
||||
icon: <CableIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||
{canRead && (usersKnown || loading) && <DataPageCard>
|
||||
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||
<UsersDataGrid
|
||||
users={visibleUsers}
|
||||
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||
serversCount={serversKnown ? serverOptions.length : null}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading && !usersKnown}
|
||||
actionsDisabled={!writeReady || saving || deleting}
|
||||
onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||
/>
|
||||
</DataPageCard>}
|
||||
</div>
|
||||
|
||||
<UserSheet
|
||||
returnFocus={formReturnFocus}
|
||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||
open={sheetOpen}
|
||||
user={editUser}
|
||||
users={users}
|
||||
servers={serverOptions}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
saving={saving}
|
||||
saveError={saveError}
|
||||
saveBlocked={saveBlocked}
|
||||
saveResume={saveResume}
|
||||
onCheckCatalogs={() => void loadLive()}
|
||||
onSave={(f) => { void handleSave(f) }}
|
||||
onClose={() => { if (!saving) { setSheetOpen(false); saveCheckpoint.current = null } }}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v && !deleting) { setDeleteTarget(null); setDeleteError(null) } }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||||
<AlertDialogCancel disabled={deleting} onClick={() => { setDeleteTarget(null); setDeleteError(null) }}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction type="button" disabled={deleting || !writeReady} aria-busy={deleting} variant="destructive" onClick={() => { void handleDelete() }}>
|
||||
{deleting ? "Удаление…" : "Удалить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+347
-345
@@ -1,395 +1,397 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
|
||||
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
|
||||
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { generateVxlanRsc } from "@/lib/vxlan-export"
|
||||
import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type?: Server["type"]
|
||||
site?: string
|
||||
country: string
|
||||
asn?: string
|
||||
enabled: boolean
|
||||
status?: Server["status"] | null
|
||||
latency?: number | null
|
||||
}
|
||||
|
||||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
||||
interface VxlanApiResponse {
|
||||
failures?: Array<{ serverId: string; serverName?: string; error: string }>
|
||||
tunnels: VxlanTunnel[]
|
||||
}
|
||||
|
||||
function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
const srv = serverFor(t.serverId)
|
||||
const lines: string[] = []
|
||||
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||
lines.push(`# RouterOS 7.x · /interface/vxlan`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface/vxlan/add \\`)
|
||||
lines.push(` name=${t.name} \\`)
|
||||
lines.push(` vni=${t.vni} \\`)
|
||||
lines.push(` port=${t.dstPort} \\`)
|
||||
lines.push(` vtep-mac-address=auto \\`)
|
||||
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
|
||||
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
|
||||
lines.push(` l2mtu=${t.l2mtu} \\`)
|
||||
if (t.comment) lines.push(` comment="${t.comment}" \\`)
|
||||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
||||
lines.push(``)
|
||||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||||
|
||||
// FDB entries for remote VTEPs
|
||||
for (const vtep of t.remoteVteps) {
|
||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||||
lines.push(` interface=${t.name} \\`)
|
||||
lines.push(` remote-ip=${vtep}`)
|
||||
lines.push(``)
|
||||
function mapBackendServer(s: BackendServer): InventoryServer {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site ?? "",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn ?? "",
|
||||
type: s.type ?? "exit-node",
|
||||
enabled: s.enabled,
|
||||
status: s.status ?? "offline",
|
||||
monitoringUnknown: s.status == null,
|
||||
latency: s.latency ?? null,
|
||||
sessions: 0,
|
||||
}
|
||||
|
||||
// Bridge
|
||||
lines.push(`# Добавить в bridge:`)
|
||||
lines.push(`/interface/bridge/port/add \\`)
|
||||
lines.push(` bridge=bridge-overlay \\`)
|
||||
lines.push(` interface=${t.name}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, tunnel, onClose }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tunnel row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TunnelRow({
|
||||
tunnel,
|
||||
onExport,
|
||||
function ExportSheet({
|
||||
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||||
}: {
|
||||
tunnel: VxlanTunnel
|
||||
onExport: () => void
|
||||
open: boolean
|
||||
tunnel: VxlanTunnel | null
|
||||
onClose: () => void
|
||||
serverById: Record<string, Server>
|
||||
receivedAt: string | null
|
||||
stale: boolean
|
||||
mock: boolean
|
||||
}) {
|
||||
const srv = serverFor(tunnel.serverId)
|
||||
const code = useMemo(
|
||||
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||
[tunnel, serverById],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||
!tunnel.enabled && "opacity-50",
|
||||
)}>
|
||||
{/* status dot */}
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||
)} />
|
||||
|
||||
{/* name */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
|
||||
</div>
|
||||
|
||||
{/* VNI */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">VNI</p>
|
||||
<p className="font-mono text-sm">{tunnel.vni}</p>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Port</p>
|
||||
<p className="font-mono text-sm">{tunnel.dstPort}</p>
|
||||
</div>
|
||||
|
||||
{/* Remote VTEPs */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
|
||||
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
|
||||
</div>
|
||||
|
||||
{/* ARP Proxy */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
|
||||
ARP {tunnel.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* MAC learning */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
|
||||
MAC {tunnel.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
tunnel.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{tunnel.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg"
|
||||
title="Экспорт VXLAN"
|
||||
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||
code,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||
function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: {
|
||||
selectedServerId: string
|
||||
selectedTunnelId: string | null
|
||||
allTunnelsHref: string
|
||||
onSelectServer: (id: string) => void
|
||||
}) {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||
|
||||
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||||
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const loadEpoch = useRef(0)
|
||||
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
const epoch = ++loadEpoch.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const [tunnelsRes, serversRes] = await Promise.all([
|
||||
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
||||
requestJson<unknown>(backendUrl, "/api/servers"),
|
||||
])
|
||||
if (epoch !== loadEpoch.current) return
|
||||
const parsedTunnels = vxlanResponseSchema.safeParse(tunnelsRes)
|
||||
const parsedServers = vxlanServersSchema.safeParse(serversRes)
|
||||
if (!parsedTunnels.success || !parsedServers.success) throw new Error("Некорректный ответ загрузки VXLAN")
|
||||
const nextTunnels = parsedTunnels.data.tunnels
|
||||
const nextServers = parsedServers.data
|
||||
const nextFailures = parsedTunnels.data.failures
|
||||
const failedIds = new Set(nextFailures.map(f => f.serverId))
|
||||
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
|
||||
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||
setFailures(nextFailures)
|
||||
setLiveError(null)
|
||||
setHasLoaded(true)
|
||||
const at = new Date().toISOString()
|
||||
setResponseReceipt(at)
|
||||
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
|
||||
} catch (e) {
|
||||
if (epoch !== loadEpoch.current) return
|
||||
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
} finally {
|
||||
if (epoch === loadEpoch.current) setLoading(false)
|
||||
}
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveTunnels([])
|
||||
setLiveServers([])
|
||||
setLiveError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void loadLive()
|
||||
})
|
||||
}, [isLive, loadLive])
|
||||
|
||||
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
|
||||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||
|
||||
const effectiveServerId = selectedServerId
|
||||
|
||||
const scopedTunnels = useMemo(() => {
|
||||
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === effectiveServerId && t.id === selectedTunnelId)
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
|
||||
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
|
||||
}, [displayTunnels, effectiveServerId, selectedTunnelId])
|
||||
|
||||
const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0]
|
||||
|
||||
const serverById = useMemo(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
)
|
||||
|
||||
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||
displayServers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
|
||||
}))
|
||||
), [displayServers, displayTunnels, isLive])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return vxlanTunnels
|
||||
if (!search) return scopedTunnels
|
||||
const q = search.toLowerCase()
|
||||
return vxlanTunnels.filter((t) =>
|
||||
return scopedTunnels.filter((t) =>
|
||||
t.name.includes(q) ||
|
||||
String(t.vni).includes(q) ||
|
||||
t.vtepIp.includes(q) ||
|
||||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||||
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [search])
|
||||
}, [search, scopedTunnels, serverById])
|
||||
|
||||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
||||
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
||||
const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures])
|
||||
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
|
||||
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый VXLAN
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={effectiveServerId}
|
||||
preserveSelection
|
||||
onSelect={onSelectServer}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || loading}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Badge variant="outline">Только просмотр</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
|
||||
<ContextTitle>Не все устройства ответили</ContextTitle>
|
||||
<ContextDescription>
|
||||
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
|
||||
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
|
||||
</ContextDescription>
|
||||
</ContextAlert>}
|
||||
{selectedTunnelId !== null && (
|
||||
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
|
||||
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
|
||||
<ContextDescription>
|
||||
<p className="wrap-anywhere">{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||||
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||
</div>
|
||||
</ContextDescription>
|
||||
</ContextAlert>
|
||||
)}
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
{isLive && liveError && selectedTunnelId === null && (
|
||||
<Alert variant="warning" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
|
||||
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
|
||||
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
|
||||
</div>}
|
||||
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
||||
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
||||
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
На опрошенных серверах нет VXLAN-интерфейсов
|
||||
</div>
|
||||
)}
|
||||
{mode === "mock" && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
Моковые данные
|
||||
</span>
|
||||
)}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка VXLAN"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Туннелей",
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активных",
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "vni",
|
||||
label: "VNI",
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов",
|
||||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||||
<p className="font-medium text-foreground">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
|
||||
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
|
||||
VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, VNI, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||||
/>
|
||||
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||||
tunnels={filtered}
|
||||
failedServerIds={failedServerIds}
|
||||
servers={displayServers}
|
||||
onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
|
||||
/>}
|
||||
</DataPageCard>
|
||||
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Имя / VTEP IP</span>
|
||||
<span>Сервер</span>
|
||||
<span>VNI</span>
|
||||
<span>Port</span>
|
||||
<span>Remote</span>
|
||||
<span />
|
||||
<span />
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<NetworkIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7 · /interface/vxlan — быстрые команды
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать VXLAN",
|
||||
lines: [
|
||||
"/interface/vxlan/add \\",
|
||||
" name=vxlan-10 \\",
|
||||
" vni=10010 \\",
|
||||
" port=8472 \\",
|
||||
" arp-proxy=yes \\",
|
||||
" mac-learning=yes",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить VTEP",
|
||||
lines: [
|
||||
"/interface/vxlan/vteps/add \\",
|
||||
" interface=vxlan-10 \\",
|
||||
" remote-ip=10.0.1.1",
|
||||
"",
|
||||
"/interface/vxlan/vteps/add \\",
|
||||
" interface=vxlan-10 \\",
|
||||
" remote-ip=10.0.2.1",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Bridge + IP",
|
||||
lines: [
|
||||
"/interface/bridge/add \\",
|
||||
" name=br-overlay",
|
||||
"",
|
||||
"/interface/bridge/port/add \\",
|
||||
" bridge=br-overlay \\",
|
||||
" interface=vxlan-10",
|
||||
"",
|
||||
"/ip/address/add \\",
|
||||
" address=10.100.0.1/24 \\",
|
||||
" interface=br-overlay",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OpsPanel title="Справка VXLAN" contentClassName="px-5 py-4">
|
||||
<p className="text-xs text-muted-foreground">Экспорт содержит интерфейс, известный MTU и статические VTEP выбранного туннеля. IP-адреса, bridge и FDB в него не входят. <a href="https://help.mikrotik.com/docs/spaces/ROS/pages/100007937/VXLAN" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Параметры VXLAN в документации MikroTik</a></p>
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportTunnel}
|
||||
tunnel={exportTunnel}
|
||||
onClose={() => setExportTunnel(null)}
|
||||
open={!!exportSnapshot}
|
||||
tunnel={exportSnapshot?.tunnel ?? null}
|
||||
onClose={() => setExportSnapshot(null)}
|
||||
serverById={exportSnapshot?.servers ?? {}}
|
||||
receivedAt={exportSnapshot?.at ?? null}
|
||||
stale={exportSnapshot?.stale ?? false}
|
||||
mock={exportSnapshot?.mock ?? !isLive}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VxlanRoute() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||
const tunnelId = params.get("tunnel")
|
||||
const rest = new URLSearchParams(params.toString())
|
||||
rest.delete("tunnel")
|
||||
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
|
||||
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||||
const next = new URLSearchParams(rest)
|
||||
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||
else next.set("server", id)
|
||||
router.push("/vxlan" + (next.size ? "?" + next.toString() : ""))
|
||||
}} />
|
||||
}
|
||||
|
||||
export default function VxlanPage() {
|
||||
return <Suspense fallback={<div role="status">Загрузка VXLAN…</div>}><VxlanRoute /></Suspense>
|
||||
}
|
||||
+1012
-441
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
export default function AccessDeniedPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||
<p className="text-muted-foreground max-w-md text-sm">
|
||||
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||
необходимых прав. Обратитесь к администратору auth-portal.
|
||||
</p>
|
||||
<a
|
||||
href="/auth/callback"
|
||||
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Войти снова
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
clearPortalHandoffFlag,
|
||||
clearToken,
|
||||
can,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getToken,
|
||||
parseHashToken,
|
||||
permissionForPath,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
setToken,
|
||||
} from "@/lib/auth"
|
||||
import { permittedAuthReturnPath } from "@/lib/auth-return-path"
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter()
|
||||
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const error = params.get("error")
|
||||
const returnPath = () => permittedAuthReturnPath(params.get("next"), pathname => {
|
||||
const permission = permissionForPath(pathname)
|
||||
return !permission || can(permission)
|
||||
}, firstAllowedPath())
|
||||
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
|
||||
const { accessToken } = parseHashToken(window.location.hash)
|
||||
if (accessToken) {
|
||||
setToken(accessToken)
|
||||
clearPortalHandoffFlag()
|
||||
const claims = getClaims()
|
||||
if (!claims) {
|
||||
clearToken()
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes("mm")) {
|
||||
setMessage("Нет доступа к приложению")
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/config", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* ignore network — proceed */
|
||||
}
|
||||
|
||||
const next = returnPath()
|
||||
if (next === "/access-denied") {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
router.replace(next)
|
||||
return
|
||||
}
|
||||
|
||||
if (getToken() && getClaims()) {
|
||||
clearPortalHandoffFlag()
|
||||
if (!getClaims()!.apps.includes("mm")) {
|
||||
router.replace("/access-denied")
|
||||
return
|
||||
}
|
||||
router.replace(returnPath())
|
||||
return
|
||||
}
|
||||
|
||||
const callback = new URL(`${window.location.origin}/auth/callback`)
|
||||
if (params.get("next")) callback.searchParams.set("next", params.get("next")!)
|
||||
const ok = redirectToPortalLogin(callback.toString())
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,18 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
/* SVG focus follows the object shape, never the text/foreignObject bounding box. */
|
||||
.network-map-focusable {
|
||||
outline: none;
|
||||
}
|
||||
.network-map-focus-ring {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.network-map-focusable:focus-visible > .network-map-focus-ring {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
@@ -39,6 +51,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 +95,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 +178,15 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--info: var(--color-violet-500);
|
||||
--info-foreground: var(--color-violet-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
--warning: var(--color-yellow-500);
|
||||
--warning-foreground: var(--color-yellow-600);
|
||||
--invert: var(--color-zinc-700);
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Path to SQLite database file
|
||||
DATABASE_PATH=./mikrotik.db
|
||||
|
||||
# Port for the Fastify server
|
||||
PORT=8000
|
||||
|
||||
# Allowed CORS origin (Next.js frontend)
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
@@ -5,3 +5,4 @@ dist/
|
||||
*.db-wal
|
||||
.env
|
||||
storage/backups/
|
||||
storage/geoip/
|
||||
+32
-3
@@ -5,10 +5,23 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY backend/package.json backend/
|
||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
||||
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||
RUN node -e "\
|
||||
const fs=require('fs');\
|
||||
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||
p.dependencies={};\
|
||||
p.devDependencies={};\
|
||||
delete p.scripts;\
|
||||
p.workspaces=['packages/*','backend'];\
|
||||
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||
"
|
||||
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||
&& npm rebuild better-sqlite3
|
||||
|
||||
FROM deps AS build
|
||||
@@ -18,10 +31,21 @@ COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& npm run build -w mikrotik-manager-backend \
|
||||
&& npm prune --omit=dev
|
||||
&& npm prune --omit=dev \
|
||||
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||
&& mkdir -p backend/node_modules
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
# Keep system packages independent of application changes and version tags.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app/data
|
||||
ARG APP_VERSION=dev
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8000
|
||||
@@ -31,7 +55,12 @@ COPY --from=build /app/package.json /app/package-lock.json ./
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/dist-operator ./backend/dist-operator
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
RUN mkdir -p /app/data
|
||||
COPY --from=build /app/backend/mcp-coverage.json ./backend/mcp-coverage.json
|
||||
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||
COPY deploy/netflow-agent/install.sh ./deploy/netflow-agent/install.sh
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
@@ -6,9 +6,9 @@ config()
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "sqlite",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
|
||||
url: process.env.DATABASE_URL ?? "postgres://mmapp:mmapp@127.0.0.1:5432/mmapp",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
CREATE TABLE `server_snapshots` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`polled_at` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`latency_ms` real,
|
||||
`ros_version` text,
|
||||
`board_name` text,
|
||||
`uptime` text,
|
||||
`cpu_load` integer,
|
||||
`free_memory` integer,
|
||||
`total_memory` integer,
|
||||
`identity_name` text,
|
||||
`raw_interfaces` text,
|
||||
`raw_ip_addresses` text,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`host` text NOT NULL,
|
||||
`port` integer DEFAULT 443 NOT NULL,
|
||||
`username` text DEFAULT 'admin' NOT NULL,
|
||||
`password` text DEFAULT '' NOT NULL,
|
||||
`use_ssl` integer DEFAULT true NOT NULL,
|
||||
`verify_ssl` integer DEFAULT false NOT NULL,
|
||||
`type` text DEFAULT 'home-router' NOT NULL,
|
||||
`site` text DEFAULT '' NOT NULL,
|
||||
`country` text DEFAULT '' NOT NULL,
|
||||
`asn` text DEFAULT '' NOT NULL,
|
||||
`comment` text DEFAULT '' NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,626 @@
|
||||
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||
-- Applied once at backend boot via src/db/migrate.ts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB,
|
||||
raw_ip_addresses JSONB,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src TEXT NOT NULL,
|
||||
dst TEXT NOT NULL,
|
||||
proto INTEGER NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop TEXT NOT NULL DEFAULT '',
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
board_name TEXT NOT NULL DEFAULT '',
|
||||
ros_version TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,620 @@
|
||||
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> 'schema_migrations'
|
||||
LOOP
|
||||
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB COMPRESSION lz4
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB COMPRESSION lz4,
|
||||
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
flags SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src INET NOT NULL,
|
||||
dst INET NOT NULL,
|
||||
proto SMALLINT NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop INET,
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (probe_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- S3-compatible storage for RouterOS backups
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_test_at TIMESTAMPTZ,
|
||||
last_test_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO backup_storage_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||
) THEN
|
||||
ALTER TABLE backup_entries
|
||||
ADD CONSTRAINT backup_entries_storage_check
|
||||
CHECK (storage IN ('local', 's3', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geoip_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
|
||||
last_check_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
country_build_at TIMESTAMPTZ,
|
||||
asn_build_at TIMESTAMPTZ,
|
||||
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO geoip_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||
-- задаются на листовых партициях в ensurePartitionFor.
|
||||
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
iface TEXT NOT NULL,
|
||||
country CHAR(2) NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
asn INTEGER NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||
) PARTITION BY RANGE (day);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
|
||||
-- Retention — prune в сервисе (последние 50 на пару server+section).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
section TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
|
||||
ON config_revisions (server_id, section, created_at DESC);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
|
||||
-- Needed to rebuild facts with the same internet dest as the network map.
|
||||
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Захват перед POST и удержание неизвестных исходов. Исторические строки не меняются.
|
||||
BEGIN;
|
||||
ALTER TABLE alert_outbox DROP CONSTRAINT IF EXISTS alert_outbox_status_check;
|
||||
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_status_check
|
||||
CHECK (status IN ('pending', 'processing', 'unknown', 'sent', 'failed'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1,4 @@
|
||||
BEGIN;
|
||||
ALTER TABLE alert_outbox ADD COLUMN IF NOT EXISTS resolution_version integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE alert_outbox ADD CONSTRAINT alert_outbox_resolution_version_check CHECK (resolution_version >= 0);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- A failed command is an attempt, not a measured packet-loss percentage.
|
||||
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS measurement_known BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE uptime_probe_samples ADD COLUMN IF NOT EXISTS observation_error TEXT;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS traffic_collection_state (
|
||||
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
|
||||
server_updated_at TIMESTAMPTZ NOT NULL,
|
||||
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_succeeded_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
|
||||
error TEXT
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
|
||||
-- On a busy existing installation, pre-create this same index CONCURRENTLY
|
||||
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS router_log_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
|
||||
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
|
||||
);
|
||||
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
-- No foreign key: collected history survives device deletion.
|
||||
CREATE TABLE IF NOT EXISTS router_log_collection_state (
|
||||
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
|
||||
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
|
||||
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS router_log_entries (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
|
||||
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
|
||||
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
|
||||
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
|
||||
message TEXT NOT NULL, generation INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
|
||||
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
|
||||
@@ -0,0 +1,101 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_settings (
|
||||
id integer PRIMARY KEY CHECK(id=1), enabled boolean NOT NULL DEFAULT false,
|
||||
requests_retention_days integer NOT NULL DEFAULT 30 CHECK(requests_retention_days BETWEEN 1 AND 365),
|
||||
audit_retention_days integer NOT NULL DEFAULT 180 CHECK(audit_retention_days BETWEEN 30 AND 3650),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
INSERT INTO mcp_settings(id) VALUES(1) ON CONFLICT DO NOTHING;
|
||||
CREATE TABLE IF NOT EXISTS mcp_connections (
|
||||
id uuid PRIMARY KEY, name text NOT NULL, owner_id text NOT NULL, owner_permissions jsonb NOT NULL,
|
||||
policy jsonb NOT NULL, generation text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_credentials (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
digest text NOT NULL UNIQUE, kind text NOT NULL CHECK(kind IN ('bearer','access','refresh')),
|
||||
expires_at timestamptz NOT NULL, revoked_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
family_id uuid, client_id text, audience text
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_credentials_connection ON mcp_credentials(connection_id);
|
||||
CREATE TABLE IF NOT EXISTS mcp_audit (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, actor_id text NOT NULL, action text NOT NULL,
|
||||
connection_id uuid, payload jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_audit_time ON mcp_audit(created_at,id);
|
||||
CREATE TABLE IF NOT EXISTS mcp_requests (
|
||||
id uuid PRIMARY KEY, connection_id uuid REFERENCES mcp_connections(id) ON DELETE SET NULL,
|
||||
actor_id text, tool text NOT NULL, server_ids bigint[] NOT NULL DEFAULT '{}',
|
||||
status text NOT NULL, input jsonb NOT NULL DEFAULT '{}', result jsonb, error_code text,
|
||||
duration_ms integer, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_requests_connection_time ON mcp_requests(connection_id,created_at,id);
|
||||
CREATE INDEX IF NOT EXISTS mcp_requests_server ON mcp_requests USING gin(server_ids);
|
||||
CREATE TABLE IF NOT EXISTS mcp_protections (
|
||||
id uuid PRIMARY KEY, object_ref jsonb NOT NULL, reason text NOT NULL DEFAULT '',
|
||||
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_plans (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id), operation_id text NOT NULL,
|
||||
operation_version integer NOT NULL, policy_version integer NOT NULL, generation text NOT NULL,
|
||||
payload jsonb NOT NULL, evidence jsonb NOT NULL, state text NOT NULL, expires_at timestamptz NOT NULL,
|
||||
approved_by text, approved_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_plans_connection ON mcp_plans(connection_id,created_at);
|
||||
CREATE TABLE IF NOT EXISTS mcp_operations (
|
||||
id uuid PRIMARY KEY, plan_id uuid NOT NULL UNIQUE REFERENCES mcp_plans(id),
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id), idempotency_key text NOT NULL,
|
||||
state text NOT NULL DEFAULT 'pending', verification text NOT NULL DEFAULT 'pending',
|
||||
result jsonb, error_code text, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
UNIQUE(connection_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_operation_steps (
|
||||
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||
server_id bigint, state text NOT NULL, before_state jsonb, intended_state jsonb, result jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
PRIMARY KEY(operation_id,step_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_budget_buckets (
|
||||
connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, bucket timestamptz NOT NULL, used integer NOT NULL CHECK(used>=0),
|
||||
PRIMARY KEY(connection_id,kind,bucket)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_read_leases (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_grants (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id) ON DELETE CASCADE,
|
||||
payload jsonb NOT NULL, revoked_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_secrets (
|
||||
id uuid PRIMARY KEY, purpose text NOT NULL, server_id bigint, ciphertext text NOT NULL,
|
||||
created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_artifacts (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
filename text NOT NULL, media_type text NOT NULL, ciphertext text NOT NULL,
|
||||
expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_clients (
|
||||
id text PRIMARY KEY, name text NOT NULL, redirect_uris jsonb NOT NULL, enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_requests (
|
||||
id uuid PRIMARY KEY, client_id text NOT NULL REFERENCES mcp_oauth_clients(id),
|
||||
redirect_uri text NOT NULL, challenge text NOT NULL, state text, resource text NOT NULL,
|
||||
scope text NOT NULL, expires_at timestamptz NOT NULL, consumed_at timestamptz,
|
||||
connection_id uuid REFERENCES mcp_connections(id), code_digest text UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_routing_profiles (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
version integer NOT NULL DEFAULT 1, config jsonb NOT NULL, state jsonb NOT NULL DEFAULT '{}',
|
||||
enabled boolean NOT NULL DEFAULT false, grant_expires_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_drift (
|
||||
id uuid PRIMARY KEY, operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||
server_id bigint, expected jsonb NOT NULL, observed jsonb NOT NULL,
|
||||
acknowledged_at timestamptz, detected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_routing_observations (
|
||||
id uuid PRIMARY KEY, src_server_id bigint NOT NULL, dst_server_id bigint NOT NULL,
|
||||
src_interface text NOT NULL, dst_interface text NOT NULL, family text NOT NULL CHECK(family IN ('ipv4','ipv6')),
|
||||
routing_table text NOT NULL DEFAULT 'main', ping_ms double precision, loss_pct double precision,
|
||||
dl_mbps double precision, ul_mbps double precision, ping_at timestamptz, bandwidth_at timestamptz,
|
||||
context_verified boolean NOT NULL DEFAULT false, source text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), CHECK(ping_ms IS NULL OR ping_ms>=0),
|
||||
CHECK(loss_pct IS NULL OR loss_pct BETWEEN 0 AND 100), CHECK(dl_mbps IS NULL OR dl_mbps>=0), CHECK(ul_mbps IS NULL OR ul_mbps>=0)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_routing_observations_leg ON mcp_routing_observations(src_server_id,dst_server_id,src_interface,family,routing_table,created_at DESC);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS credential_id uuid REFERENCES mcp_credentials(id);
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS generation text;
|
||||
ALTER TABLE mcp_routing_profiles ADD COLUMN IF NOT EXISTS policy_version integer;
|
||||
CREATE INDEX IF NOT EXISTS mcp_routing_profiles_enabled ON mcp_routing_profiles(updated_at) WHERE enabled;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mcp_drift_open_step ON mcp_drift(operation_id,(expected->>'stepIndex')) WHERE acknowledged_at IS NULL;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_valid_until timestamptz NOT NULL DEFAULT now();
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_operation_checkpoints (
|
||||
operation_id uuid NOT NULL REFERENCES mcp_operations(id), step_index integer NOT NULL,
|
||||
before_sealed text NOT NULL, after_sealed text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(operation_id,step_index)
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_management_channels (
|
||||
id uuid PRIMARY KEY, server_id bigint NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
||||
config_sealed text NOT NULL, identity_hash text NOT NULL, attestation text NOT NULL,
|
||||
created_by text NOT NULL, generation text NOT NULL, expires_at timestamptz NOT NULL,
|
||||
verified_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_path_checks (
|
||||
id uuid PRIMARY KEY, connection_id uuid NOT NULL REFERENCES mcp_connections(id),
|
||||
profile_hash text NOT NULL, candidate_id text NOT NULL, status text NOT NULL CHECK(status IN ('passed','failed','unknown')),
|
||||
evidence jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_path_checks_latest ON mcp_path_checks(connection_id,profile_hash,candidate_id,created_at DESC);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,7 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_recovery_intents (
|
||||
operation_id uuid PRIMARY KEY REFERENCES mcp_operations(id), state text NOT NULL,
|
||||
authority_profile_id uuid NOT NULL REFERENCES mcp_routing_profiles(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_unauthorized_buckets (
|
||||
bucket timestamptz NOT NULL, client_hash text NOT NULL, reason text NOT NULL,
|
||||
attempts integer NOT NULL DEFAULT 1, PRIMARY KEY(bucket,client_hash,reason)
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,4 @@
|
||||
BEGIN;
|
||||
ALTER TABLE user_interface_bindings DROP CONSTRAINT IF EXISTS user_interface_bindings_interface_type_check;
|
||||
ALTER TABLE user_interface_bindings ADD CONSTRAINT user_interface_bindings_interface_type_check CHECK(interface_type IN ('ether','bridge','vlan','vxlan','gre','wg','ipsec','other'));
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_owner_revocations (
|
||||
owner_id text PRIMARY KEY, invalid_before timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS mcp_portal_events (
|
||||
id uuid PRIMARY KEY, owner_id text NOT NULL, payload_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS mcp_portal_events_time ON mcp_portal_events(created_at);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,3 @@
|
||||
BEGIN;
|
||||
ALTER TABLE mcp_connections ADD COLUMN IF NOT EXISTS owner_claims_issued_at timestamptz NOT NULL DEFAULT to_timestamp(0);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE INDEX IF NOT EXISTS mcp_audit_handshake_cooldown
|
||||
ON mcp_audit(connection_id, created_at DESC)
|
||||
WHERE action = 'connection.handshake.start';
|
||||
CREATE INDEX IF NOT EXISTS mcp_credentials_readiness_expired
|
||||
ON mcp_credentials(connection_id, expires_at)
|
||||
WHERE client_id = 'mcp-readiness-probe';
|
||||
CREATE INDEX IF NOT EXISTS mcp_grants_credential
|
||||
ON mcp_grants(connection_id, (payload->>'credentialId'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mcp_artifacts ADD COLUMN IF NOT EXISTS access_scope jsonb;
|
||||
CREATE INDEX IF NOT EXISTS mcp_artifacts_expiry ON mcp_artifacts(expires_at);
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
ALTER TABLE servers ADD COLUMN IF NOT EXISTS routing_endpoint_epoch uuid NOT NULL DEFAULT gen_random_uuid();
|
||||
CREATE OR REPLACE FUNCTION mm_routing_endpoint_epoch() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(NEW.host,NEW.port,NEW.username,NEW.password,NEW.use_ssl,NEW.verify_ssl,NEW.enabled)
|
||||
IS DISTINCT FROM ROW(OLD.host,OLD.port,OLD.username,OLD.password,OLD.use_ssl,OLD.verify_ssl,OLD.enabled) THEN
|
||||
NEW.routing_endpoint_epoch := gen_random_uuid();
|
||||
ELSE
|
||||
NEW.routing_endpoint_epoch := OLD.routing_endpoint_epoch;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS mm_routing_endpoint_epoch ON servers;
|
||||
CREATE TRIGGER mm_routing_endpoint_epoch BEFORE UPDATE ON servers FOR EACH ROW EXECUTE FUNCTION mm_routing_endpoint_epoch();
|
||||
ALTER TABLE mcp_routing_observations ADD COLUMN IF NOT EXISTS source_proof text;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='mcp_routing_observation_source_proof' AND conrelid='mcp_routing_observations'::regclass) THEN
|
||||
ALTER TABLE mcp_routing_observations ADD CONSTRAINT mcp_routing_observation_source_proof CHECK(source_proof IS NULL OR source_proof ~ '^[a-f0-9]{64}$');
|
||||
END IF;
|
||||
END $$;
|
||||
-- Existing measurements stay readable but unsigned. Never attest old samples
|
||||
-- using today's endpoint: that would invent capture-time provenance.
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
-- Service-owned exact read-back must survive a subsequent network probe failure.
|
||||
-- Generic after_sealed remains an array of resource snapshots.
|
||||
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_sealed text;
|
||||
ALTER TABLE mcp_operation_checkpoints ADD COLUMN IF NOT EXISTS configuration_proof text;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS mcp_scoped_recovery_intents (
|
||||
source_operation_id uuid NOT NULL REFERENCES mcp_operations(id),
|
||||
source_step_index integer NOT NULL CHECK(source_step_index>=0),
|
||||
source_receipt_id uuid NOT NULL,
|
||||
source_configuration_proof text NOT NULL,
|
||||
recovery_receipt_id uuid NOT NULL UNIQUE,
|
||||
recovery_operation_id uuid REFERENCES mcp_operations(id),
|
||||
mode text NOT NULL CHECK(mode IN ('manual','reserved')),
|
||||
authority_profile_id uuid REFERENCES mcp_routing_profiles(id),
|
||||
authority_profile_version integer,
|
||||
intent_sealed text NOT NULL,
|
||||
intent_proof text NOT NULL,
|
||||
state text NOT NULL CHECK(state IN ('intent','verified','unknown')),
|
||||
deadline timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz,
|
||||
verified_sealed text,
|
||||
verified_proof text,
|
||||
PRIMARY KEY(source_operation_id,source_step_index),
|
||||
CHECK(mode='manual' OR authority_profile_id IS NOT NULL AND authority_profile_version IS NOT NULL),
|
||||
CHECK((state='verified')=(verified_sealed IS NOT NULL AND verified_proof IS NOT NULL))
|
||||
);
|
||||
COMMIT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Immutable commit evidence shared by ingestion and REST/MCP fact rebuild.
|
||||
-- No raw flow payload, IP addresses, credentials or notification contents belong here.
|
||||
CREATE TABLE IF NOT EXISTS flow_write_receipts (
|
||||
batch_id uuid PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('ingest', 'facts', 'rebuild')),
|
||||
source_proof text NOT NULL CHECK (source_proof ~ '^[a-f0-9]{64}$'),
|
||||
result jsonb NOT NULL CHECK (jsonb_typeof(result) = 'object' AND octet_length(result::text) <= 65536),
|
||||
committed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Receipts are intentionally not pruned: an owned unknown batch may still need them.
|
||||
Loaded 100 of 3924 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user