Compare commits

..
Author SHA1 Message Date
Denozordec 5efd0339fb feat(logs): добавить раздел логов с историей и живым потоком
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 22:13:48 +07:00
Denozordec e461dc21bd fix(traffic): строить непрерывную историю коротких периодов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m3s
Docker images / frontend-image (push) Successful in 2m13s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 31s
Docker images / backend-image (push) Successful in 1m37s
Docker images / publish-release (push) Successful in 13s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 19:27:30 +07:00
Denozordec 2852b11234 test(netflow): зафиксировать приёмку на двух маршрутизаторах
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m27s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 1m6s
Docker images / backend-image (push) Successful in 1m40s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 15:47:50 +07:00
Denozordec 0abdac82de fix(netflow): показывать текущие счётчики приёмника 2026-10-16 15:45:10 +07:00
Denozordec db81cf0f97 feat(netflow): автоматизировать настройку туннеля коллектора 2026-10-16 15:03:23 +07:00
Denozordec fcca92706b chore(docs): сохранить проверку защиты от переполнения очереди подключений 2026-10-16 09:58:16 +07:00
Denozordec 4881306f0b chore(docs): сохранить диагностику задержек подключения к серверу 2026-10-16 09:53:53 +07:00
Denozordec ef229ca9c4 chore(docs): сохранить результат отключения небезопасных служб 2026-10-16 01:56:22 +07:00
Denozordec 25ae76dc3f fix(api): уточнить причины отказа входа на маршрутизатор 2026-10-16 01:32:29 +07:00
Denozordec 552d5f831f chore(docs): зафиксировать приёмку обновления в рабочем окружении 2026-10-16 00:53:30 +07:00
Denozordec e6a2e4009d fix(routes): читать рекурсивные маршруты с незаданными свойствами
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 5m2s
Docker images / frontend-image (push) Successful in 2m46s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
Docker images / publish-release (push) Successful in 15s
Docker images / notify-webhook (push) Successful in 2s
2026-10-16 00:28:45 +07:00
Denozordec eb24a08981 fix(map): восстановить легенду и убрать лишние рамки фокуса 2026-10-15 23:36:19 +07:00
Denozordec 1c11a133f3 chore(docs): зафиксировать обновление карты в рабочем окружении 2026-10-15 23:24:54 +07:00
Denozordec 0f0d3aa060 fix(filters): восстановить связь рекурсивного шлюза после опроса 2026-10-15 23:17:42 +07:00
Denozordec 042407f03e fix(ci): подключить тесты к базе в сети задания
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 5m11s
Docker images / frontend-image (push) Successful in 2m15s
Docker images / updater-image (push) Successful in 41s
Docker images / backend-image (push) Successful in 1m41s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
2026-10-15 22:14:14 +07:00
Denozordec b2cf71fcc7 fix(db): индексировать историю для очистки планировщика
Docker images / prepare-release (push) Successful in 14s
Docker images / backend-test (push) Failing after 2m49s
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 2m39s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Skipped
2026-10-15 22:04:38 +07:00
Denozordec fda7f353ad fix(map): сохранить узлы при датах опроса из базы данных 2026-10-15 21:51:43 +07:00
107 changed files with 4680 additions and 75 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
deploy/netflow-agent/*.sh text eol=lf
deploy/netflow-agent/*.py text eol=lf
+92 -5
View File
@@ -57,16 +57,31 @@ jobs:
run: |
set -euo pipefail
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" mm-pg 2>/dev/null || true
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
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 \
-p "127.0.0.1:${HOST_PORT}:5432" \
"${NETWORK_ARGS[@]}" \
postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
echo "DATABASE_URL=postgres://mmapp:[email protected]:${HOST_PORT}/mmapp" >> "${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
@@ -79,15 +94,27 @@ jobs:
- 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:-}" mm-pg 2>/dev/null || true
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
backend-image:
needs:
@@ -128,6 +155,8 @@ jobs:
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.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
@@ -302,12 +331,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:
@@ -393,6 +479,7 @@ jobs:
- prepare-release
- backend-image
- frontend-image
- netflow-agent-image
runs-on: ubuntu-latest
env:
WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
+8
View File
@@ -62,3 +62,11 @@ 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/
+4
View File
@@ -568,3 +568,7 @@ docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
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).
+4 -1
View File
@@ -58,6 +58,7 @@ import {
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -84,6 +85,7 @@ function fmtMs(ms: number): string {
}
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
@@ -759,7 +761,7 @@ function DataCollectionContent() {
return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const enabled = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
@@ -959,6 +961,7 @@ function DataCollectionContent() {
</Alert>
)}
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
{isLive && (
<>
<KpiStatGrid
+7 -1
View File
@@ -1676,11 +1676,17 @@ function FiltersPage() {
}
const openEdit = (rule: FilterRule) => {
if (mutationsLocked || applyLock.current) return
// The observed catalog can have new DB IDs after refresh. Recover only an
// explicit recursive reference, never reinterpret a named GRE interface.
const routes = recRoutesByServer[selectedServerId] ?? []
const recursive = isRecursiveGatewayRef(rule.gatewayTunnelId)
? routes.find(r => r.id === rule.gatewayTunnelId.slice(4)) ?? pickRecursiveRouteByGatewayHop(routes, rule.gateway)
: undefined
setSheetInitial({
action: rule.action ?? "route",
community: rule.community,
gateway: rule.gateway,
gatewayTunnelId: rule.gatewayTunnelId,
gatewayTunnelId: recursive ? `rec:${recursive.id}` : rule.gatewayTunnelId,
gatewayKind: isRecursiveGatewayRef(rule.gatewayTunnelId) ? "recursive" : "gre",
description: rule.description,
})
+2
View File
@@ -0,0 +1,2 @@
import { LogsWorkspace } from "@/components/logs/logs-workspace"
export default function LogsPage() { return <LogsWorkspace /> }
+14 -10
View File
@@ -11,6 +11,7 @@ import {Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription} from "@
import {usePrefersReducedMotion} from "@/hooks/use-prefers-reduced-motion"
import {toast} from "sonner"
import { PageHeader } from "@/components/page-header"
import { NetworkMapLegend } from "@/components/network-map-legend"
import {
servers as mockServers,
greTunnels as mockGreTunnels,
@@ -556,7 +557,7 @@ function GreEdgeMetricBadge({
"Клик — панель справа."
return (
<g
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
role="button" aria-label={"Метрики "+(outerSummary??"связи")} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`}
style={{ cursor: "pointer" }}
@@ -564,6 +565,7 @@ function GreEdgeMetricBadge({
onClick={(e) => { e.stopPropagation(); onOpen(e) }}
>
<title>{hint}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect
x={-bw / 2}
y={-bh / 2}
@@ -607,7 +609,7 @@ function NetflowRateBadge({
const bh = showDir ? 32 : 20
return (
<g
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="focus-visible:outline-2 focus-visible:outline-ring"
role={onOpen?"button":undefined} aria-label={onOpen?"Поток NetFlow между узлами":undefined} tabIndex={onOpen?0:undefined} className="network-map-focusable"
onKeyDown={e=>{if(onOpen&&(e.key==="Enter"||e.key===" ")){e.preventDefault();e.stopPropagation();onOpen(e)}}}
transform={`translate(${mx},${my})`}
style={{ cursor: onOpen ? "pointer" : "default" }}
@@ -617,6 +619,7 @@ function NetflowRateBadge({
<title>
Поток NetFlow между узлами (как в «Трафик»: 5 мин, без overlay/mesh). Скорость канала — отдельно.
</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 4} y={-bh / 2 - 4} width={bw + 8} height={bh + 8} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
<rect
x={-bw / 2}
y={-bh / 2}
@@ -687,7 +690,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
const ts = TYPE_STYLE[n.type]
return (
<g className="focus-visible:outline-2 focus-visible:outline-ring" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
<g className="network-map-focusable" role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${n.x},${n.y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -699,6 +702,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
>
<circle className="network-map-focus-ring" r={ts.r + 6} fill="none" stroke="var(--ring)" strokeWidth="2" />
{n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
</circle>
@@ -707,7 +711,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
<circle r={ts.r + 12} fill={ss.glow} />
)}
{isSel && (
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" />
<circle r={ts.r + 10} fill="none" stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" opacity="0.45" />
)}
<circle r={ts.r} fill={ss.fill} stroke={ss.stroke} strokeWidth={isSel ? 2.5 : 1.8} />
<g transform={`translate(${ts.r - 10},-${ts.r - 10})`}>
@@ -807,7 +811,7 @@ function ServiceNode({
const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return (
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="focus-visible:outline-2 focus-visible:outline-ring"
<g role="button" aria-label={(destMode==="countries"?"Страна ":"Сервис ")+label} aria-pressed={isSel} tabIndex={isVis?0:-1} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
@@ -816,6 +820,7 @@ function ServiceNode({
onClick={(e) => { e.stopPropagation(); onClick() }}
>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
<rect className="network-map-focus-ring" x={-bw / 2 - 3} y={-bh / 2 - 3} width={bw + 6} height={bh + 6} rx="9" fill="none" stroke="var(--ring)" strokeWidth="2" />
{isSel && (
<rect
x={-bw / 2 - 6}
@@ -925,12 +930,13 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPo
}) {
const r = 18
return (
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="focus-visible:outline-2 focus-visible:outline-ring"
<g role="button" aria-label={"Аплинк "+wan.name} aria-pressed={isSel} tabIndex={0} className="network-map-focusable"
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
transform={`translate(${x},${y})`}
style={{ cursor: isDragged ? "grabbing" : "grab" }}
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
onClick={e => { e.stopPropagation(); onSelect() }}>
<circle className="network-map-focus-ring" r={r + 4} fill="none" stroke="var(--ring)" strokeWidth="2" />
{active && (
<circle r={r + 6} fill={color} opacity="0.12">
</circle>
@@ -1718,6 +1724,7 @@ function NetworkMapContent() {
if (!svg) return
// Clone with full viewBox (reset pan/zoom)
const clone = svg.cloneNode(true) as SVGSVGElement
clone.querySelectorAll(".network-map-focus-ring").forEach(ring => ring.remove())
clone.setAttribute("viewBox", `0 0 ${W} ${H}`)
clone.setAttribute("width", String(W))
clone.setAttribute("height", String(H))
@@ -2252,10 +2259,7 @@ function NetworkMapContent() {
{/* ── Canvas ── */}
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm">
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
</details>
<NetworkMapLegend />
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
<svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
+12
View File
@@ -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 {
+2
View File
@@ -17,3 +17,5 @@ AUTH_REQUIRED=false
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Optional: enabled by the NetFlow agent Compose installer. Linux Unix socket only.
# FLOW_AGENT_SOCKET=/run/mm-netflow-host/agent.sock
+1
View File
@@ -57,6 +57,7 @@ COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.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
@@ -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);
+26
View File
@@ -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);
+6 -4
View File
@@ -4,6 +4,8 @@
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
"type": "module",
"scripts": {
"test:logs": "tsx src/services/router-logs-model.test.ts && tsx src/plugins/auth.logs.test.ts && tsx src/services/mikrotik-logs.test.ts",
"test:logs:pg": "tsx src/services/router-logs-sql.test.ts",
"test:sidebar-counts": "tsx src/services/complete-count.test.ts && tsx src/routes/sidebar-counts.test.ts",
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
@@ -22,8 +24,8 @@
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
"test:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
@@ -33,13 +35,13 @@
"test": "node scripts/test.mjs",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts && tsx src/services/traffic-flow-agent-install.test.ts && tsx src/services/traffic-flow-agent-client.test.ts && tsx src/services/traffic-flow-operation-queue.test.ts && tsx src/routes/traffic-flow-host.test.ts",
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
"test:all": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:server-privacy": "tsx src/routes/server-secrets-sql.test.ts"
},
"dependencies": {
@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import pg from "pg"
import { env } from "../config.js"
// No bootstrap/migrations against public tables: this test needs only a connection.
const pool = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 8000, statement_timeout: 10000 })
try {
const client = await pool.connect()
try {
await client.query("BEGIN")
// Temporary shadow table: public scheduler history is never changed.
await client.query("CREATE TEMP TABLE scheduler_runs(id integer PRIMARY KEY, finished_at timestamptz NOT NULL) ON COMMIT DROP")
await client.query(`INSERT INTO scheduler_runs SELECT i, now() - CASE WHEN i=1 THEN interval '60 days' ELSE i * interval '1 second' END FROM generate_series(1,20000) AS i`)
await client.query("ANALYZE scheduler_runs")
const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString()
const plan = async () => JSON.stringify((await client.query("EXPLAIN (FORMAT JSON) SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows)
assert.match(await plan(), /Seq Scan/)
const migration = readFileSync(new URL("../../drizzle/0013_scheduler_retention_index.sql", import.meta.url), "utf8")
await client.query(migration)
await client.query(migration)
assert.match(await plan(), /Index Scan|Bitmap Index Scan/)
assert.deepEqual((await client.query("SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows, [{ id: 1 }])
const index = (await client.query("SELECT indisvalid FROM pg_index WHERE indrelid = 'pg_temp.scheduler_runs'::regclass AND indexrelid::regclass::text LIKE '%idx_scheduler_runs_finished_at'")).rows
assert.deepEqual(index, [{ indisvalid: true }])
console.log("Scheduler retention: actual migration/idempotence/valid index/selective plan/result preservation passed on 20,000 temporary rows")
} finally {
await client.query("ROLLBACK")
client.release()
}
} finally {
await pool.end()
}
+17
View File
@@ -20,6 +20,22 @@ function ts(name: string) {
return timestamp(name, { withTimezone: true, mode: "string" as const })
}
export const routerLogSettings = pgTable("router_log_settings", {
id: integer("id").primaryKey(), enabled: boolean("enabled").notNull().default(false),
intervalSec: integer("interval_sec").notNull().default(10), retentionDays: integer("retention_days").notNull().default(14),
})
export const routerLogCollectionState = pgTable("router_log_collection_state", {
serverId: integer("server_id").primaryKey(), serverName: text("server_name").notNull(), host: text("host").notNull(),
generation: integer("generation").notNull().default(0), snapshot: jsonb("snapshot").$type<string[]>().notNull().default([]),
lastCollectedAt: ts("last_collected_at"), lastError: text("last_error"), gapAt: ts("gap_at"), nextPollAt: ts("next_poll_at"), failures: integer("failures").notNull().default(0),
})
export const routerLogEntries = pgTable("router_log_entries", {
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(), serverId: integer("server_id").notNull(),
serverName: text("server_name").notNull(), host: text("host").notNull(), routerId: text("router_id").notNull(), rawTime: text("raw_time").notNull(),
eventAt: ts("event_at"), receivedAt: ts("received_at").notNull(), effectiveAt: ts("effective_at").notNull(),
topics: text("topics").array().notNull(), level: text("level").notNull(), message: text("message").notNull(), generation: integer("generation").notNull(),
}, table => [index("router_logs_server_time").on(table.serverId, table.effectiveAt, table.id), index("router_logs_time").on(table.effectiveAt, table.id), index("router_logs_level_time").on(table.level, table.effectiveAt), index("router_logs_topics").using("gin", table.topics), index("router_logs_received").on(table.receivedAt), index("router_logs_server_cursor").on(table.serverId, table.id)])
function idIdentity() {
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
}
@@ -649,6 +665,7 @@ export const schedulerRuns = pgTable("scheduler_runs", {
resultJson: jsonb("result_json"),
}, (t) => [
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
index("idx_scheduler_runs_finished_at").on(t.finishedAt),
])
export const events = pgTable("events", {
+8
View File
@@ -37,8 +37,11 @@ import firewallRoutes from "./routes/firewall.js"
import greRoutes from "./routes/gre.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import logsRoutes from "./routes/logs.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { agentSocket } from "./services/traffic-flow-agent-client.js"
import { reconcileFlowHost, withFlowConfigurationLock } from "./services/traffic-flow-host-agent.js"
import { initGeoip } from "./services/traffic-flow-geoip.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
@@ -142,11 +145,16 @@ export async function buildApp(opts?: {
await app.register(greRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
await app.register(logsRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
await refreshScheduler()
await initGeoip()
await startTrafficFlowListener()
if (agentSocket()) {
try { await withFlowConfigurationLock(reconcileFlowHost) }
catch { app.log.warn("NetFlow: агент хоста не синхронизирован; проверьте состояние в настройках NetFlow") }
}
app.addHook("onClose", async () => {
stopScheduler()
stopTrafficFlowListener()
+2
View File
@@ -42,6 +42,8 @@ type Rule = {
}
const RULES: Rule[] = [
{ methods: ["GET", "PUT"], match: p => p === "/api/logs/settings", permission: "mm:settings:admin" },
{ methods: ["GET"], match: p => p === "/api/logs" || p.startsWith("/api/logs/"), permission: "mm:servers:read" },
{ methods: ["GET"], match: path => path === "/api/traffic/flow/host-files", permission: "mm:traffic:write" },
{ methods: ["GET"], match: path => path === "/api/geoip", permission: "mm:traffic:read" },
{ methods: ["PUT", "POST"], match: path => path === "/api/geoip" || path === "/api/geoip/update", permission: "mm:traffic:write" },
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "logs-test-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.NODE_ENV = "test"
const { default: authPlugin, requireApiAuth } = await import("./auth.js")
const { default: logsRoutes } = await import("../routes/logs.js")
const app = Fastify()
await app.register(authPlugin)
app.addHook("preHandler", requireApiAuth)
await app.register(logsRoutes, { prefix: "/api" })
const token = (permission: string) => new SignJWT({ sub: "logs-test", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("logs-test-secret"))
for (const path of ["/api/logs", "/api/logs/live", "/api/logs/summary", "/api/logs/export", "/api/logs/1/context", "/api/logs/settings"]) {
assert.equal((await app.inject({ url: path })).statusCode, 401)
assert.equal((await app.inject({ url: path, headers: { authorization: "Bearer " + await token("mm:network:write") } })).statusCode, 403)
}
for (const permission of ["mm:servers:read", "mm:servers:write", "mm:servers:admin"]) {
assert.equal((await app.inject({ url: "/api/logs?levels=invalid", headers: { authorization: "Bearer " + await token(permission) } })).statusCode, 400)
assert.equal((await app.inject({ url: "/api/logs/settings", headers: { authorization: "Bearer " + await token(permission) } })).statusCode, 403)
}
assert.equal((await app.inject({ method: "PUT", url: "/api/logs/settings", payload: { retentionDays: 0 }, headers: { authorization: "Bearer " + await token("mm:settings:admin") } })).statusCode, 400)
await app.close()
console.log("PASS logs JWT: history/live/export/context require servers:read, settings require settings:admin before DB access")
+1 -1
View File
@@ -16,7 +16,7 @@ export function installDatabaseRequestGate(app: FastifyInstance): void {
if (!path.startsWith("/api/") || path === "/api/auth/config") return
const original = options.handler
const operation = path === "/api/system/database/backup" || path === "/api/system/database/restore"
const stream = path === "/api/traffic/flow/live" || /^\/api\/traffic\/servers\/:id\/live$/.test(path)
const stream = path === "/api/logs/live" || path === "/api/traffic/flow/live" || /^\/api\/traffic\/servers\/:id\/live$/.test(path)
options.handler = async function (request, reply) {
const run = async () => await original.call(this, request, reply)
if (operation) return gate.exclusive(run)
+100
View File
@@ -0,0 +1,100 @@
import type { FastifyPluginAsync } from "fastify"
import { setTimeout as sleep } from "node:timers/promises"
import { once } from "node:events"
import { logFiltersSchema, logPageQuerySchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { env } from "../config.js"
import { registerDatabaseStreamAbort } from "../plugins/database-request-gate.js"
import { getLogSettings, subscribeLogDevice, updateLogSettings } from "../services/router-logs-collector.js"
import { readLogPage, readLogSummary, readLogEntry, readLogContext, readLogStream, logColumns } from "../services/router-logs-query.js"
import { csvCell, logWhere } from "../services/router-logs-model.js"
import { refreshScheduler } from "../services/scheduler.js"
import { isSchedulerJobsPaused } from "../services/scheduler-running.js"
const logsRoutes: FastifyPluginAsync = async app => {
const streams = new Set<AbortController>()
app.addHook("preClose", async () => { for (const stream of streams) stream.abort() })
app.get("/logs", async req => {
const parsed = logPageQuerySchema.safeParse(req.query)
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
return readLogPage(parsed.data, parsed.data)
})
app.get("/logs/summary", async req => {
const parsed = logFiltersSchema.safeParse(req.query)
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
return readLogSummary(parsed.data)
})
app.get("/logs/settings", async () => getLogSettings())
app.put("/logs/settings", async (req, reply) => {
const parsed = logSettingsPatchSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: "Некорректные настройки сборщика логов" })
const settings = await updateLogSettings(parsed.data)
await refreshScheduler()
return { ok: true, settings }
})
app.get("/logs/export", async (req, reply) => {
const parsed = logFiltersSchema.safeParse(req.query)
const format = (req.query as { format?: string }).format ?? "csv"
if (!parsed.success || !["csv", "json"].includes(format)) return reply.code(400).send({ error: "Некорректный экспорт" })
const where = logWhere(parsed.data)
const rows = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT 50001`, where.values)).rows
if (rows.length > 50000) return reply.code(422).send({ error: "Найдено более 50 000 событий. Сузьте период или фильтры для экспорта." })
reply.header("Content-Disposition", `attachment; filename="router-logs.${format}"`)
if (format === "json") return reply.type("application/json; charset=utf-8").send(JSON.stringify(rows))
const columns = ["effectiveAt", "serverName", "host", "level", "topics", "message", "rawTime", "receivedAt", "timeKind"]
const csv = [columns.map(csvCell).join(","), ...rows.map(row => columns.map(key => csvCell(Array.isArray(row[key]) ? row[key].join(",") : row[key] instanceof Date ? row[key].toISOString() : row[key])).join(","))].join("\r\n")
return reply.type("text/csv; charset=utf-8").send("\uFEFF" + csv)
})
app.get("/logs/live", async (req, reply) => {
const parsed = logFiltersSchema.safeParse(req.query)
const cursor = (req.query as { cursor?: string }).cursor
if (!parsed.success || !parsed.data.serverId || !cursor || !/^\d{1,20}$/.test(cursor)) return reply.code(400).send({ error: "Выберите устройство и передайте курсор истории" })
const filters = parsed.data
const available = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
if (!available.rowCount) return reply.code(404).send({ error: "Устройство отключено или удалено" })
if (isSchedulerJobsPaused()) return reply.code(503).send({ error: "Сбор приостановлен для обслуживания БД" })
const controller = new AbortController()
streams.add(controller)
const onClose = () => controller.abort()
registerDatabaseStreamAbort(req, () => {
if (reply.raw.headersSent && !reply.raw.destroyed) reply.raw.write('event: reset\ndata: {"reason":"database-maintenance"}\n\n')
controller.abort()
})
req.raw.on("close", onClose)
const unsubscribe = subscribeLogDevice(filters.serverId!)
reply.hijack()
req.raw.setTimeout(0); reply.raw.setTimeout(0)
const headers: Record<string, string> = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" }
const origin = req.headers.origin
if (origin && (env.CORS_ORIGIN === "*" || env.CORS_ORIGIN === origin)) { headers["Access-Control-Allow-Origin"] = origin; headers["Access-Control-Allow-Credentials"] = "true"; headers.Vary = "Origin" }
reply.raw.writeHead(200, headers)
let after = cursor
try {
while (!controller.signal.aborted && !isSchedulerJobsPaused()) {
// Long connections must not outlive the JWT they were authenticated with.
if (env.authRequired && req.user.exp && Date.now() >= req.user.exp * 1000) break
const exists = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
if (!exists.rowCount) { reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Устройство отключено или удалено" })}\n\n`); break }
const batch = await readLogStream(filters, after)
after = batch.cursor
if (!reply.raw.write(`event: batch\nid: ${after}\ndata: ${JSON.stringify(batch)}\n\n`)) await once(reply.raw, "drain", { signal: controller.signal })
if (batch.entries.length < 200) await sleep(1000, undefined, { signal: controller.signal })
}
} catch (error) {
if (!controller.signal.aborted && !reply.raw.destroyed) reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Поток прерван; выполняется переподключение" })}\n\n`)
req.log.debug({ err: error }, "Logs SSE closed")
} finally { streams.delete(controller); unsubscribe(); req.raw.off("close", onClose); reply.raw.end() }
})
app.get("/logs/:id", async (req, reply) => {
const id = String((req.params as { id: string }).id)
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
const entry = await readLogEntry(id)
return entry ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
})
app.get("/logs/:id/context", async (req, reply) => {
const id = String((req.params as { id: string }).id)
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
return await readLogContext(id) ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
})
}
export default logsRoutes
@@ -9,6 +9,7 @@ let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
let propertyFailure = false, propertyReads = 0
const applied: Array<{ rows: typeof row[]; source: string }> = []
const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
readProperty: async (_server, _id, property) => { propertyReads++; if (propertyFailure) throw Error("Свойство недоступно"); return property === "disabled" ? { ret: "false" } : [] },
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
apply: async (device, rows, source) => {
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
@@ -85,6 +87,14 @@ rawFailure=true
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
rawFailure=false;rawPayload=[]
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
rawPayload=[{...observed,disabled:undefined,"check-gateway":undefined}]
const recovered=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(recovered.statusCode,200);assert.equal(recovered.json().routes[0].disabled,false);assert.equal(recovered.json().routes[0].checkGateway,"none");assert.equal(propertyReads,2)
propertyFailure=true
const beforePropertyFailure=savedSnapshots
const failedProperty=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().routes,undefined);assert.equal(savedSnapshots,beforePropertyFailure)
propertyFailure=false
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
for (const type of ["home-router", "exit-node"] as const) {
+15 -5
View File
@@ -1,5 +1,5 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog, readRecursiveEffectiveProperties, type RecursiveObservedProperty } from "../services/recursive-route-catalog.js"
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise<unknown>
readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer
@@ -187,7 +188,15 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
const deps: RecursiveRouteDependencies = {
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
readRaw: server => MikrotikClient.fromServer(server).get<unknown>("/ip/route"),
readRaw: (server, purpose) => {
const client = MikrotikClient.fromServer(server)
// Every ownership marker is a comment. Keep all commented rows, including
// unsupported ones that must block an unsafe replacement of the managed set.
return purpose === "gateways"
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
},
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
@@ -203,7 +212,8 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) {
@@ -217,7 +227,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
+2 -4
View File
@@ -12,7 +12,7 @@ import {
type TestConnectionRequest,
} from "../types/server.js"
import { pollServer } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "../services/mikrotik.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import {
createServer,
@@ -56,9 +56,7 @@ const serversRoutes: FastifyPluginAsyncZod = async (app) => {
let message = "Не удалось подключиться"
if (err instanceof MikrotikError) {
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
else message = `Ошибка RouterOS API: ${err.statusCode}`
message = routerOsAccessHint(err.statusCode, err.body) ?? `Ошибка RouterOS API: ${err.statusCode}`
} else if (err instanceof Error) {
const msg = err.message.toLowerCase()
if (msg.includes("timeout") || msg.includes("abort"))
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { emptyHostStatus } from "../services/traffic-flow-agent-client.js"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "flow-host-test-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.NODE_ENV = "test"
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
const app = Fastify()
await app.register(authPlugin)
app.addHook("preHandler", requireAuth)
let applied = 0, fail = false
let status = emptyHostStatus(false)
await app.register(createTrafficFlowHostRoutes({
status: async () => status,
reconcile: async () => { applied++; if (fail) throw Error("Туннель не изменён: конфликт адресов"); return status },
}), { prefix: "/api" })
async function token(permission: string) {
return new SignJWT({ sub: "fixture", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("flow-host-test-secret"))
}
for (const permission of ["mm:dashboard:read", "mm:traffic:read", "mm:traffic:write", "mm:traffic:admin"]) {
const headers = { authorization: "Bearer " + await token(permission) }
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })).statusCode, permission === "mm:dashboard:read" ? 403 : 200)
const before = applied
const response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
const allowed = permission.endsWith(":write") || permission.endsWith(":admin")
assert.equal(response.statusCode, allowed ? 409 : 403)
assert.equal(applied, before + Number(allowed))
}
assert.equal((await app.inject({ method: "GET", url: "/api/traffic/flow/host-status" })).statusCode, 401)
const headers = { authorization: "Bearer " + await token("mm:traffic:write") }
status = { ...emptyHostStatus(true), reachable: true, interfaceUp: true, configMatches: true }
let response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 200); assert.equal(response.json().hostStatus.configMatches, true)
fail = true
response = await app.inject({ method: "POST", url: "/api/traffic/flow/host-reconcile", headers })
assert.equal(response.statusCode, 502); assert.match(response.json().error, /конфликт адресов/)
status = emptyHostStatus(true, "offline")
response = await app.inject({ method: "GET", url: "/api/traffic/flow/host-status", headers })
assert.equal(response.json().configMatches, null); assert.equal(response.json().reachable, false)
await app.close()
console.log("NetFlow host HTTP/JWT: read/write permissions, manual mode, reconcile success/conflict and offline status")
+20
View File
@@ -0,0 +1,20 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function createTrafficFlowHostRoutes(deps: {
status: () => Promise<TrafficFlowHostStatus>
reconcile: () => Promise<TrafficFlowHostStatus>
}): FastifyPluginAsyncZod {
return async app => {
app.get("/traffic/flow/host-status", async () => deps.status())
app.post("/traffic/flow/host-reconcile", async (_req, reply) => {
try {
const hostStatus = await deps.reconcile()
if (!hostStatus.configured) return reply.status(409).send({ error: "Автоматизация хоста NetFlow не настроена" })
return { ok: true, hostStatus }
} catch (error) {
return reply.status(502).send({ error: error instanceof Error ? error.message : "Настройки хоста не применены" })
}
})
}
}
+21 -3
View File
@@ -8,6 +8,9 @@ import {
} from "@mmapp/contracts/traffic-flow"
import { createTrafficFlowPurgeRoutes } from "./traffic-flow-purge.js"
import { createTrafficFlowSettingsRoutes } from "./traffic-flow-settings.js"
import { createTrafficFlowHostRoutes } from "./traffic-flow-host.js"
import { getFlowHostStatus, reconcileFlowHost, withFlowConfigurationLock } from "../services/traffic-flow-host-agent.js"
import { agentSocket } from "../services/traffic-flow-agent-client.js"
import {
ensureHostKeys,
getTrafficFlowSettingsRow,
@@ -16,6 +19,7 @@ import {
} from "../services/traffic-flow-settings.js"
import {
getFlowListenerState,
getFlowRuntimeCounters,
purgeTrafficFlowStore,
startTrafficFlowListener,
listFlowTalkers,
@@ -150,11 +154,25 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
}
const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
await app.register(createTrafficFlowHostRoutes({
status: getFlowHostStatus,
reconcile: () => withFlowConfigurationLock(reconcileFlowHost),
}))
await app.register(createTrafficFlowSettingsRoutes({
read: () => toTrafficFlowSettingsDto(getFlowListenerState()),
update: async patch => { await updateTrafficFlowSettings(patch) },
read: async () => {
const settings = await toTrafficFlowSettingsDto(getFlowListenerState())
const { packetsReceived, lastExporterIp, lastError, lastDatagramAt } = await getFlowRuntimeCounters()
return { ...settings, packetsReceived, lastExporterIp, lastError, lastDatagramAt }
},
update: async patch => { await withFlowConfigurationLock(async () => {
const current = await getTrafficFlowSettingsRow()
if (agentSocket() && (["collectorIp", "prefix", "flowListenPort", "wgListenPort"] as const).some(key => patch[key] !== undefined && patch[key] !== current[key])) {
throw Object.assign(new Error("Адрес, префикс и порты закреплены установкой агента; повторите установщик хоста"), { statusCode: 409 })
}
await updateTrafficFlowSettings(patch)
}) },
startListener: startTrafficFlowListener,
ensureKeys: ensureHostKeys,
ensureKeys: () => withFlowConfigurationLock(ensureHostKeys),
hasPrivateKey: async () => Boolean((await getTrafficFlowSettingsRow()).hostPrivateKey),
hostFiles: listTrafficFlowHostFiles,
}))
+50 -1
View File
@@ -1,7 +1,56 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { MikrotikClient } from "./mikrotik.js"
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "./mikrotik.js"
test("RouterOS access diagnostics distinguish login saturation without assuming a wrong password or retrying", async () => {
let requests = 0
const body = '{"detail":"too many concurrent logins (6)","error":500,"message":"Internal Server Error"}'
const server = createServer((req, res) => { requests++; req.resume(); res.writeHead(500); res.end(body) })
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const address = server.address(); assert(address && typeof address !== "string")
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
try {
for (const operation of [() => client.get("/interface"), () => client.put("/own", {})]) {
await assert.rejects(operation, (error: unknown) => {
assert(error instanceof MikrotikError)
assert.equal(error.statusCode, 500); assert.equal(error.body, body)
assert.match(error.message, /превышен лимит одновременных входов/)
return true
})
}
assert.equal(requests, 2, "Neither reads nor writes may silently retry a refused login")
assert.match(routerOsAccessHint(401, '{"message":"Unauthorized"}')!, /ограничения входа/)
assert.doesNotMatch(routerOsAccessHint(401, "")!, /Неверный логин или пароль/)
for (const unknown of ["not JSON", "null", '{"detail":6}', '{"detail":"disk failure"}']) {
assert.equal(routerOsAccessHint(500, unknown), undefined)
}
assert.equal(routerOsAccessHint(400, body), undefined)
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS print preserves native query arrays and property selection", async () => {
let received: unknown, target = "", method = ""
const server = createServer(async (req, res) => {
target = req.url ?? ""; method = req.method ?? ""
let body = ""; for await (const chunk of req) body += chunk.toString()
received = JSON.parse(body)
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const address = server.address(); assert(address && typeof address !== "string")
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
try {
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
assert.deepEqual(result, [{ ".id": "*1" }])
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
let active=0,peak=0,requests=0
+28 -4
View File
@@ -104,7 +104,7 @@ function rosRequest(
function rosPost(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
body: Record<string, string | string[]>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<unknown> {
@@ -595,7 +595,7 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
async post<T>(path: string, body: Record<string, string | string[]>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
@@ -715,13 +715,21 @@ export class MikrotikClient {
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
}
async getLogBuffer(): Promise<RosLogEntry[]> {
const entries = await this.get<unknown>("/log?buffer=memory&.proplist=.id,time,topics,message", 8_000)
if (!Array.isArray(entries) || entries.some(e => !e || [".id", "time", "topics", "message"].some(k => typeof e[k] !== "string"))) {
throw new Error("RouterOS: некорректный буфер логов")
}
return entries as RosLogEntry[]
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new RangeError("limit должен быть положительным целым числом.")
}
// REST query parameters filter properties; limit is not a print row limit.
// Read only the default buffer and retain its latest entries in native order.
const entries = await this.get<RosLogEntry[]>("/log?buffer=memory&.proplist=.id,time,topics,message")
const entries = await this.getLogBuffer()
return entries.slice(-Math.min(limit, 200))
}
@@ -1110,13 +1118,29 @@ export class MikrotikClient {
// ── Error type ─────────────────────────────────────────────────────────────────
/** HTTP 401 alone does not distinguish wrong credentials from a refused login. */
export function routerOsAccessHint(statusCode: number, body: string): string | undefined {
if (statusCode === 401) return "RouterOS отклонил авторизацию: проверьте учётные данные, права API/REST и ограничения входа"
if (statusCode === 403) return "RouterOS запретил доступ к REST API"
if (statusCode === 500) {
try {
const detail: unknown = JSON.parse(body)?.detail
if (typeof detail === "string" && /^too many concurrent logins(?:\s*\(\d+\))?$/i.test(detail.trim())) {
return "RouterOS занят: превышен лимит одновременных входов"
}
} catch { /* Keep an unrecognized response unchanged. */ }
}
return undefined
}
export class MikrotikError extends Error {
constructor(
public readonly statusCode: number,
public readonly path: string,
public readonly body: string,
) {
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
const hint = routerOsAccessHint(statusCode, body)
super(`${hint ? `${hint}. ` : ""}RouterOS API error ${statusCode} on ${path}: ${body}`)
this.name = "MikrotikError"
}
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { readRecursiveEffectiveProperties, parseRecursiveCatalog } from "./recursive-route-catalog.js"
const row = { ".id": "*1", static: "true", "dst-address": "10.9.9.2/32", gateway: "192.0.2.1%gre-test", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", comment: "MikrotikManager:recursive Тест" }
const calls: string[] = []
const result = await readRecursiveEffectiveProperties([row], async (id, property) => {
calls.push(id + ":" + property)
return property === "disabled" ? { ret: "false" } : []
})
assert.equal(parseRecursiveCatalog(result)[0].disabled, false)
assert.equal(parseRecursiveCatalog(result)[0].checkGateway, "none")
assert.deepEqual(calls.sort(), ["*1:check-gateway", "*1:disabled"])
assert.equal("disabled" in row, false)
let reads = 0
const complete = { ...row, disabled: "true", "check-gateway": "ping" }
assert.deepEqual(await readRecursiveEffectiveProperties([complete, { ".id": "*other", comment: "user-owned" }], async () => { reads++; return null }), [complete, { ".id": "*other", comment: "user-owned" }])
assert.equal(reads, 0)
for (const payload of [null, {}, [null], [{ ...row, ".id": "" }], [row, row]]) {
await assert.rejects(readRecursiveEffectiveProperties(payload, async () => { reads++; return null }))
assert.equal(reads, 0)
}
for (const property of ["disabled", "check-gateway"] as const) {
for (const response of [null, {}, { ret: false }, { ret: "" }, { ret: "unknown" }, [{}], ...(property === "disabled" ? [[]] : [])]) {
const input = { ...complete }; delete (input as Record<string, unknown>)[property]
await assert.rejects(readRecursiveEffectiveProperties([input], async () => response), /Не подтверждён/)
}
}
await assert.rejects(readRecursiveEffectiveProperties([row], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
const explicit = await readRecursiveEffectiveProperties([row], async (_id, property) => ({ ret: property === "disabled" ? "true" : "bfd" }))
assert.equal(parseRecursiveCatalog(explicit)[0].disabled, true)
assert.equal(parseRecursiveCatalog(explicit)[0].checkGateway, "bfd")
// Explicit invalid values are not replaced with defaults or re-read.
const invalid = { ...complete, disabled: "unknown" }
const untouched = await readRecursiveEffectiveProperties([invalid], async () => { throw Error("unexpected read") })
assert.throws(() => parseRecursiveCatalog(untouched))
let active = 0, maximum = 0
await readRecursiveEffectiveProperties(Array.from({ length: 12 }, (_, i) => ({ ...row, ".id": "*" + i })), async (_id, property) => {
maximum = Math.max(maximum, ++active)
await new Promise(resolve => setTimeout(resolve, 1)); active--
return { ret: property === "disabled" ? "false" : "arp" }
})
assert.equal(maximum, 2)
console.log("Recursive effective properties: omitted flags/unset check, strict envelopes, ownership, no mutation, errors and bounded reads passed")
@@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array<Record<string, unknown>> {
}
function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined }
function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null }
export type RecursiveObservedProperty = "disabled" | "check-gateway"
/** RouterOS print can omit unset properties. Ask get for the effective value,
* rather than interpreting an incomplete snapshot as enabled/no-check. */
export async function readRecursiveEffectiveProperties(
payload: unknown,
readProperty: (id: string, property: RecursiveObservedProperty) => Promise<unknown>,
): Promise<Array<Record<string, unknown>>> {
const rows = rawRows(payload).map(row => ({ ...row }))
const tasks: Array<{ row: Record<string, unknown>; property: RecursiveObservedProperty }> = []
for (const row of rows) {
if (typeof row.comment !== "string" || !isOwnedRecursiveComment(row.comment)) continue
for (const property of ["disabled", "check-gateway"] as const) {
if (row[property] === undefined) tasks.push({ row, property })
}
}
let cursor = 0, failed = false
await Promise.all(Array.from({ length: Math.min(2, tasks.length) }, async () => {
while (!failed && cursor < tasks.length) {
const { row, property } = tasks[cursor++]!
try {
const response = await readProperty(String(row[".id"]), property)
// An empty successful /get reply means the optional check is unset.
// Do not treat null, malformed envelopes or failed reads as defaults.
const unset = property === "check-gateway" && Array.isArray(response) && response.length === 0
const value = response && typeof response === "object" && !Array.isArray(response) && "ret" in response ? response.ret : undefined
if (property === "disabled") {
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled маршрута " + row[".id"])
row.disabled = observedBoolean(value) ? "true" : "false"
} else {
if (!unset && (typeof value !== "string" || !["none", "arp", "ping", "bfd"].includes(value))) throw new Error("Не подтверждён check-gateway маршрута " + row[".id"])
row[property] = unset ? "none" : value
}
} catch (error) { failed = true; throw error }
}
}))
return rows
}
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
const id = String(row[".id"])
@@ -0,0 +1,128 @@
import type { LogSettings } from "@mmapp/contracts"
import { pool, dbAll } from "../db/index.js"
import type { Server } from "../db/schema.js"
import { listEnabledMonitoringServers } from "./monitoring-server-catalog.js"
import { MikrotikClient } from "./mikrotik.js"
import { logLevel, logSignature, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
import { endSchedulerJob, isSchedulerJobsPaused, tryBeginSchedulerJob } from "./scheduler-running.js"
import type { RouterLogsRunSnapshot } from "../types/scheduler-run-snapshot.js"
export async function getLogSettings(): Promise<LogSettings> {
const { rows } = await pool.query<LogSettings>(`SELECT enabled, interval_sec AS "intervalSec", retention_days AS "retentionDays" FROM router_log_settings WHERE id=1`)
if (!rows[0]) throw new Error("Настройки логов не найдены; проверьте миграцию БД")
return rows[0]
}
export async function updateLogSettings(patch: Partial<LogSettings>) {
await pool.query(`UPDATE router_log_settings SET enabled=COALESCE($1,enabled), interval_sec=COALESCE($2,interval_sec), retention_days=COALESCE($3,retention_days) WHERE id=1`, [patch.enabled, patch.intervalSec, patch.retentionDays])
return getLogSettings()
}
const liveDevices = new Map<number, number>()
export function subscribeLogDevice(serverId: number) {
liveDevices.set(serverId, (liveDevices.get(serverId) ?? 0) + 1)
let released = false
return () => { if (released) return; released = true; const n = (liveDevices.get(serverId) ?? 1) - 1; if (n) liveDevices.set(serverId, n); else liveDevices.delete(serverId) }
}
const inflight = new Map<number, Promise<{ added: number; gap: boolean; error?: string }>>()
let active = 0
const waiters: (() => void)[] = []
async function slot() { if (active >= 4) await new Promise<void>(resolve => waiters.push(resolve)); else active++ }
function release() { const next = waiters.shift(); if (next) next(); else active-- }
async function collectDevice(server: Server, settings: LogSettings, force: boolean) {
await slot()
const client = await pool.connect().catch(error => { release(); throw error })
try {
await client.query("BEGIN")
const lock = await client.query<{ locked: boolean }>("SELECT pg_try_advisory_xact_lock(872314,$1) AS locked", [server.id])
if (!lock.rows[0].locked || isSchedulerJobsPaused()) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
const admission = await client.query<{ enabled: boolean }>("SELECT enabled FROM router_log_settings WHERE id=1")
if (!admission.rows[0]?.enabled) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
await client.query(`INSERT INTO router_log_collection_state(server_id,server_name,host) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, [server.id, server.name, server.host])
const prev = (await client.query<{ snapshot: string[]; generation: number; last_collected_at: Date | null; next_poll_at: Date | null; failures: number }>("SELECT * FROM router_log_collection_state WHERE server_id=$1 FOR UPDATE", [server.id])).rows[0]
const now = Date.now()
const interval = liveDevices.has(server.id) ? 2000 : settings.intervalSec * 1000
const backoff = prev.failures ? Math.min(60_000, interval * 2 ** Math.min(prev.failures, 5)) : interval
if (!force && (prev.next_poll_at && prev.next_poll_at.getTime() > now || prev.last_collected_at && now - prev.last_collected_at.getTime() < interval)) {
await client.query("COMMIT"); return { added: 0, gap: false }
}
let rows
try { rows = await MikrotikClient.fromServer(server).getLogBuffer() }
catch (error) {
const message = (error instanceof Error ? error.message : String(error)).slice(0, 1024)
await client.query(`UPDATE router_log_collection_state SET last_error=$2, failures=failures+1, next_poll_at=$3,server_name=$4,host=$5 WHERE server_id=$1`, [server.id, message, new Date(now + backoff).toISOString(), server.name, server.host])
await client.query("COMMIT")
return { added: 0, gap: false, error: message }
}
const current = await client.query("SELECT id FROM servers WHERE id=$1 AND enabled=true AND updated_at=$2", [server.id, server.updatedAt])
if (!current.rowCount) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
const snapshot = rows.map(logSignature)
const { offset, gap } = reconcileLogSnapshot(prev.snapshot, snapshot)
const generation = prev.generation + (gap ? 1 : 0)
const receivedAt = new Date().toISOString()
const newRows = rows.slice(offset).map(row => {
const topics = row.topics.split(",").map(t => t.trim()).filter(Boolean)
const eventAt = parseLogTime(row.time)
return { server_id: server.id, server_name: server.name, host: server.host, router_id: row[".id"], raw_time: row.time, event_at: eventAt, received_at: receivedAt, effective_at: eventAt ?? receivedAt, topics, level: logLevel(topics), message: row.message, generation }
})
if (newRows.length) await client.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation)
SELECT server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation
FROM jsonb_to_recordset($1::jsonb) AS x(server_id int,server_name text,host text,router_id text,raw_time text,event_at timestamptz,received_at timestamptz,effective_at timestamptz,topics text[],level text,message text,generation int)`, [JSON.stringify(newRows)])
await client.query(`UPDATE router_log_collection_state SET snapshot=$2::jsonb,generation=$3,last_collected_at=$4,last_error=NULL,failures=0,next_poll_at=NULL,gap_at=CASE WHEN $5 THEN $4::timestamptz ELSE gap_at END,server_name=$6,host=$7 WHERE server_id=$1`, [server.id, JSON.stringify(snapshot), generation, receivedAt, gap, server.name, server.host])
await client.query("COMMIT")
return { added: newRows.length, gap }
} catch (error) { await client.query("ROLLBACK"); throw error }
finally { client.release(); release() }
}
function collectShared(server: Server, settings: LogSettings, force = false) {
const pending = inflight.get(server.id)
if (pending) return pending
const task = collectDevice(server, settings, force)
inflight.set(server.id, task)
void task.then(() => inflight.delete(server.id), () => inflight.delete(server.id))
return task
}
export async function collectRouterLogsOnce(): Promise<RouterLogsRunSnapshot> {
const settings = await getLogSettings()
const result: RouterLogsRunSnapshot = { v: 1, job: "router_logs", sampledAt: new Date().toISOString(), servers: [], errors: [] }
if (!settings.enabled || isSchedulerJobsPaused()) return { ...result, skipped: true }
const servers = await listEnabledMonitoringServers()
result.servers = await Promise.all(servers.map(async server => {
try { return { serverId: server.id, name: server.name, ...await collectShared(server, settings) } }
catch (error) { return { serverId: server.id, name: server.name, added: 0, gap: false, error: error instanceof Error ? error.message : String(error) } }
}))
result.errors = result.servers.flatMap(row => row.error ? [`${row.name}: ${row.error}`] : [])
await pruneRouterLogHistory(settings.retentionDays)
return result
}
let timer: ReturnType<typeof setInterval> | undefined
let retentionTimer: ReturnType<typeof setInterval> | undefined
export async function pruneRouterLogHistory(retentionDays: number) {
await pool.query(`DELETE FROM router_log_entries WHERE id IN (SELECT id FROM router_log_entries WHERE received_at < now() - ($1 * interval '1 day') ORDER BY received_at LIMIT 10000)`, [retentionDays])
}
export function stopLogsLiveTimer() { if (timer) clearInterval(timer); if (retentionTimer) clearInterval(retentionTimer); timer = undefined; retentionTimer = undefined }
export function startLogsLiveTimer() {
stopLogsLiveTimer()
// Retention continues even when collection is disabled, and drains before DB maintenance.
const prune = () => {
if (!tryBeginSchedulerJob("router_logs_retention")) return
void getLogSettings().then(settings => pruneRouterLogHistory(settings.retentionDays)).catch(() => {}).finally(() => endSchedulerJob("router_logs_retention"))
}
retentionTimer = setInterval(prune, 60_000); retentionTimer.unref()
timer = setInterval(() => {
if (!liveDevices.size || !tryBeginSchedulerJob("router_logs_live")) return
void (async () => {
try {
const settings = await getLogSettings()
if (!settings.enabled) return
const servers = (await listEnabledMonitoringServers()).filter(s => liveDevices.has(s.id))
await Promise.allSettled(servers.map(server => collectShared(server, settings)))
} finally { endSchedulerJob("router_logs_live") }
})().catch(() => {})
}, 1000)
timer.unref()
}
export const logStateSql = `SELECT server_id AS "serverId", server_name AS "serverName", host, generation, last_collected_at AS "lastCollectedAt", last_error AS "lastError", gap_at AS "gapAt", next_poll_at AS "nextPollAt", failures FROM router_log_collection_state`
export async function getLogStates(serverId?: number) { return dbAll(logStateSql + (serverId ? " WHERE server_id=$1" : " ORDER BY server_name"), serverId ? [serverId] : []) }
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { csvCell, logLevel, logSignature, logWhere, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
import { permissionForRequest } from "../lib/permissions.js"
test("buffer overlap survives rotation; real repeated messages and reused IDs remain events", () => {
const row = (id: string, message = "same message", time = "08:00:00") => ({ ".id": id, time, message, topics: "system,info" })
const a = row("*A"), b = row("*B"), c = row("*C"), d = row("*D")
const sig = (...rows: ReturnType<typeof row>[]) => rows.map(logSignature)
assert.deepEqual(reconcileLogSnapshot([], sig(a, b)), { offset: 0, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b, c), sig(b, c, d)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b, c)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(row("*A", "new boot"))), { offset: 0, gap: true })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), []), { offset: 0, gap: true })
assert.deepEqual(reconcileLogSnapshot(sig(a), sig(row("*A", "same message", "08:00:01"))), { offset: 0, gap: true })
const previous = Array.from({ length: 100_000 }, (_, i) => String(i))
assert.equal(reconcileLogSnapshot(previous, [...previous.slice(100), "next"]).offset, 99900)
})
test("severity and time never fabricate RouterOS facts", () => {
assert.equal(logLevel(["system", "error", "critical"]), "critical")
assert.equal(logLevel(["firewall"]), "unknown")
for (const time of ["08:00:00", "oct/16 08:00:00", "2026-10-16 08:00:00", "bad"]) assert.equal(parseLogTime(time), null)
assert.equal(parseLogTime("2026-10-16T08:00:00+07:00"), "2026-10-16T01:00:00.000Z")
assert.equal(parseLogTime("2026-02-30T08:00:00Z"), null)
})
test("filters compile parameterized literal search with OR inside fields", () => {
const filters = logFiltersSchema.parse({ serverId: "1", levels: "critical,error", topics: "system,firewall", q: "%' OR true --" })
const query = logWhere(filters)
assert.ok(query.sql.includes("level = ANY")); assert.ok(query.sql.includes("topics &&"))
assert.ok(!query.sql.includes(filters.q)); assert.ok(query.values.includes(filters.q))
assert.equal(logFiltersSchema.safeParse({ levels: "panic" }).success, false)
assert.equal(logFiltersSchema.safeParse({ from: "2026-10-17T00:00:00Z", to: "2026-10-16T00:00:00Z" }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ enabled: true, retentionDays: 0 }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ intervalSec: 1 }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ unknown: true }).success, false)
})
test("log endpoints use existing read and admin grants", () => {
for (const path of ["/api/logs", "/api/logs/live", "/api/logs/summary", "/api/logs/export", "/api/logs/12/context"]) assert.equal(permissionForRequest("GET", path), "mm:servers:read")
for (const method of ["GET", "PUT"]) assert.equal(permissionForRequest(method, "/api/logs/settings"), "mm:settings:admin")
})
test("CSV escapes quotes, multiline text and spreadsheet formulas", () => {
assert.equal(csvCell('hello,"router"\nline'), '"hello,""router""\nline"')
assert.equal(csvCell("=HYPERLINK(1)"), '"\'=HYPERLINK(1)"')
})
+53
View File
@@ -0,0 +1,53 @@
import type { LogFilters, LogLevel } from "@mmapp/contracts"
import type { RosLogEntry } from "../types/server.js"
import { createHash } from "node:crypto"
export function logLevel(topics: string[]): LogLevel {
return (["critical", "error", "warning", "info", "debug"] as const).find(level => topics.includes(level)) ?? "unknown"
}
export function logSignature(entry: RosLogEntry): string {
return createHash("sha256").update(JSON.stringify([entry[".id"], entry.time, entry.topics, entry.message])).digest("hex")
}
/** Longest suffix/prefix overlap, linear in buffer size. Signatures include RouterOS identity. */
export function reconcileLogSnapshot(previous: string[], current: string[]) {
if (!previous.length) return { offset: 0, gap: false }
if (!current.length) return { offset: 0, gap: true }
const combined: (string | null)[] = [...current, null, ...previous]
const pi = new Array<number>(combined.length).fill(0)
for (let i = 1; i < combined.length; i++) {
let j = pi[i - 1]
while (j && combined[i] !== combined[j]) j = pi[j - 1]
if (combined[i] === combined[j]) j++
pi[i] = j
}
const offset = pi[pi.length - 1]
return { offset, gap: offset === 0 }
}
export function parseLogTime(raw: string): string | null {
// RouterOS timestamps without an explicit offset cannot be interpreted as UTC safely.
if (!/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(raw)) return null
const date = raw.slice(0, 10), calendar = new Date(date + "T00:00:00Z")
if (!Number.isFinite(calendar.getTime()) || calendar.toISOString().slice(0, 10) !== date || Number(raw.slice(11, 13)) > 23) return null
const ms = Date.parse(raw.replace(" ", "T"))
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
}
export function logWhere(filters: LogFilters, extra: string[] = [], initial: unknown[] = []) {
const values = [...initial], clauses = [...extra]
const bind = (value: unknown) => { values.push(value); return `$${values.length}` }
if (filters.serverId) clauses.push(`server_id = ${bind(filters.serverId)}`)
if (filters.from) clauses.push(`effective_at >= ${bind(filters.from)}::timestamptz`)
if (filters.to) clauses.push(`effective_at <= ${bind(filters.to)}::timestamptz`)
if (filters.levels) clauses.push(`level = ANY(${bind(filters.levels.split(","))}::text[])`)
if (filters.topics) clauses.push(`topics && ${bind(filters.topics.split(",").map(t => t.trim()).filter(Boolean))}::text[]`)
if (filters.q) clauses.push(`strpos(lower(message), lower(${bind(filters.q)})) > 0`)
if (filters.preset === "login-failure") clauses.push(`(lower(message) LIKE '%login failure%' OR lower(message) LIKE '%login failed%' OR lower(message) LIKE '%authentication failed%')`)
if (filters.preset === "interfaces") clauses.push(`topics && ARRAY['interface','bridge','ethernet','wireless','wifi']::text[]`)
if (filters.preset === "routing") clauses.push(`topics && ARRAY['route','routing','bgp','ospf','bfd']::text[]`)
if (filters.preset === "firewall") clauses.push(`'firewall' = ANY(topics)`)
return { sql: clauses.length ? clauses.join(" AND ") : "TRUE", values }
}
export function csvCell(value: unknown): string {
let text = String(value ?? "")
if (/^(?:\s*[=+\-@]|[\t\r])/.test(text)) text = "'" + text
return '"' + text.replaceAll('"', '""') + '"'
}
+68
View File
@@ -0,0 +1,68 @@
import type { PoolClient } from "pg"
import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { getLogSettings, getLogStates } from "./router-logs-collector.js"
import { logWhere } from "./router-logs-model.js"
export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation`
export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") }
export function decodeLogCursor(cursor: string): [string, string] {
try {
const value = JSON.parse(Buffer.from(cursor, "base64url").toString())
if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && Number.isFinite(Date.parse(value[0])) && /^\d+$/.test(value[1])) return value as [string, string]
} catch { /* invalid cursor */ }
throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 })
}
export async function logWatermark(client: PoolClient, serverId?: number) {
const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${serverId ? " WHERE server_id=$1" : ""}`, serverId ? [serverId] : [])
return res.rows[0].id
}
export async function readLogPage(filters: LogFilters, options: { cursor?: string; watermark?: string; limit: number }) {
const client = await pool.connect()
try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
const watermark = options.watermark ?? await logWatermark(client, filters.serverId)
const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark]
if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) }
const where = logWhere(filters, conditions, values)
const rows = (await client.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT $${where.values.length + 1}`, [...where.values, options.limit + 1])).rows
await client.query("COMMIT")
const entries = rows.slice(0, options.limit)
return { entries, nextCursor: rows.length > options.limit ? encodeLogCursor(entries[entries.length - 1]) : null, watermark }
} catch (error) { await client.query("ROLLBACK"); throw error } finally { client.release() }
}
export async function readLogSummary(filters: LogFilters) {
const where = logWhere(filters)
const from = filters.from ? Date.parse(filters.from) : Date.now() - 900_000
const to = filters.to ? Date.parse(filters.to) : Date.now()
const bucketSec = Math.max(1, Math.ceil((to - from) / 1000 / 60))
const { rows } = await pool.query<{ at: Date; level: string; count: number }>(`SELECT to_timestamp(floor(extract(epoch FROM effective_at)/$${where.values.length + 1})*$${where.values.length + 1}) AS at,level,count(*)::int AS count FROM router_log_entries WHERE ${where.sql} GROUP BY 1,2 ORDER BY 1`, [...where.values, bucketSec])
const levels = Object.fromEntries(logLevels.map(level => [level, 0])) as Record<typeof logLevels[number], number>
const buckets = new Map<string, { at: string } & typeof levels>()
for (const row of rows) {
const at = row.at.toISOString()
const level = row.level as typeof logLevels[number]
levels[level] += row.count
if (!buckets.has(at)) buckets.set(at, { at, ...Object.fromEntries(logLevels.map(l => [l, 0])) } as { at: string } & typeof levels)
buckets.get(at)![level] += row.count
}
return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId) }
}
export async function readLogEntry(id: string) {
return (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1`, [id])).rows[0] ?? null
}
export async function readLogContext(id: string) {
const entry = await readLogEntry(id)
if (!entry) return null
const before = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
return { entry, before: before.reverse(), after }
}
export async function readLogStream(filters: LogFilters, cursor: string) {
// A committed upper bound is safe per device: writers are serialized by its advisory lock.
// Advance across non-matching events too, so a quiet filtered stream never rescans its backlog.
const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor])
const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark])
const entries = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY id LIMIT 200`, where.values)).rows
return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled }
}
@@ -0,0 +1,108 @@
/** Integration/performance tests use a fresh, disposable schema, never application tables. */
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { randomBytes } from "node:crypto"
import pg from "pg"
import { logEntrySchema, logFiltersSchema, logPageSchema, logSummarySchema } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { env } from "../config.js"
import { collectRouterLogsOnce, getLogStates, subscribeLogDevice, updateLogSettings } from "./router-logs-collector.js"
import { readLogContext, readLogPage, readLogStream, readLogSummary } from "./router-logs-query.js"
const schema = "test_router_logs_" + randomBytes(6).toString("hex")
const admin = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 3000 })
try { await admin.query("SELECT 1") } catch { await admin.end(); console.error("PostgreSQL недоступен; SQL/live/performance проверки не выполнены"); process.exit(1) }
if (!/^[a-z_0-9]+$/.test(schema)) throw new Error("Unsafe test schema")
await admin.query(`CREATE SCHEMA ${schema}`)
pool.options.options = `-c search_path=${schema}`
let entries = Array.from({ length: 301 }, (_, i) => ({ ".id": `*${i}`, time: "12:00:00", topics: "system,info", message: "entry " + i }))
let requests = 0, denied = false
const router = createServer((req, res) => {
requests++
const url = new URL(req.url!, "http://127.0.0.1")
assert.equal(url.pathname, "/rest/log")
assert.equal(url.searchParams.get("buffer"), "memory")
res.writeHead(denied ? 401 : 200, { "content-type": "application/json" }); res.end(JSON.stringify(denied ? { error: "denied" } : entries))
})
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
const address = router.address()
assert.ok(address && typeof address !== "string")
try {
const initial = await readFile(new URL("../../drizzle/0000_postgresql.sql", import.meta.url), "utf8")
const serverSql = initial.match(/CREATE TABLE IF NOT EXISTS servers \([\s\S]*?\n\);/)![0]
await pool.query(serverSql)
await pool.query(await readFile(new URL("../../drizzle/0014_router_logs.sql", import.meta.url), "utf8"))
const { rows: [server] } = await pool.query<{ id: number }>(`INSERT INTO servers(name,host,port,use_ssl) VALUES('logs-fixture','127.0.0.1',$1,false) RETURNING id`, [address.port])
const serverId = Number(server.id)
await updateLogSettings({ enabled: true, intervalSec: 5 })
const filter = logFiltersSchema.parse({ serverId })
await collectRouterLogsOnce()
let page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(filter, { limit: 200 }))))
assert.equal(page.entries.length, 200); assert.ok(page.nextCursor)
const older = await readLogPage(filter, { limit: 200, cursor: page.nextCursor!, watermark: page.watermark })
assert.equal(older.entries.length, 101)
assert.equal(new Set([...older.entries, ...page.entries].map(e => e.id)).size, 301)
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.equal((await readLogSummary(filter)).total, 301, "Repeated snapshot is not reimported")
entries = [...entries.slice(5), { ".id": "*302", time: "12:00:01", topics: "system,error,critical", message: "login failure" }]
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
// Concurrent clients and background collection must issue one read, not duplicate writes.
const beforeRequests = requests
const a = subscribeLogDevice(serverId), b = subscribeLogDevice(serverId)
await Promise.all([collectRouterLogsOnce(), collectRouterLogsOnce(), collectRouterLogsOnce()])
a(); b()
assert.equal(requests - beforeRequests, 1)
const stream = await readLogStream(filter, page.watermark)
assert.equal(stream.entries.length, 1)
const unmatched = await readLogStream(logFiltersSchema.parse({ serverId, q: "not-present-in-buffer" }), page.watermark)
assert.equal(unmatched.entries.length, 0)
assert.equal(unmatched.cursor, stream.cursor, "Filtered stream advances across non-matching entries")
const critical = logEntrySchema.parse(JSON.parse(JSON.stringify(stream.entries[0])))
assert.equal(critical.level, "critical"); assert.equal(critical.timeKind, "receipt")
const context = await readLogContext(critical.id)
assert.equal(context?.before.length, 10)
const filtered = logFiltersSchema.parse({ serverId, levels: "critical,error", q: "LOGIN" })
assert.equal((await readLogPage(filtered, { limit: 200 })).entries.length, 1)
const summary = logSummarySchema.parse(JSON.parse(JSON.stringify(await readLogSummary(filtered))))
assert.equal(summary.total, 1); assert.equal(summary.levels.critical, 1)
entries = [{ ".id": "*1", time: "01:00:00", topics: "system,warning", message: "reboot" }]
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.equal((await getLogStates(serverId))[0].generation, 1)
assert.ok((await getLogStates(serverId))[0].gapAt)
denied = true
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.ok((await getLogStates(serverId))[0].lastError)
const deniedRequests = requests
await collectRouterLogsOnce()
assert.equal(requests, deniedRequests, "Error backoff prevents repeated logins")
denied = false
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute',next_poll_at=NULL")
await collectRouterLogsOnce()
assert.equal((await getLogStates(serverId))[0].lastError, null)
// Receipt-based retention, including events whose device timestamp is wrong.
await pool.query(`UPDATE router_log_entries SET received_at=now()-interval '15 days' WHERE id=$1`, [critical.id])
await collectRouterLogsOnce()
assert.equal((await pool.query("SELECT id FROM router_log_entries WHERE id=$1", [critical.id])).rowCount, 0)
await pool.query("DELETE FROM servers WHERE id=$1", [serverId])
assert.ok((await readLogSummary(filter)).total > 0, "Deleting device preserves collected history")
const count = process.env.LOGS_PERFORMANCE === "1" ? 1_000_000 : 5000
const started = performance.now()
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT 999,'perf','192.0.2.1',i::text,'12:00:00',now(),now()-(i * interval '0.1 seconds'),ARRAY['system','info'],'info','performance event '||i,0 FROM generate_series(1,$1::int) AS i`, [count])
const performanceFilters = logFiltersSchema.parse({ serverId: 999, from: new Date(Date.now() - 14 * 86400000).toISOString() })
const readStarted = performance.now()
page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(performanceFilters, { limit: 200 }))))
assert.equal(page.entries.length, 200)
const performanceSummary = await readLogSummary(performanceFilters)
assert.equal(performanceSummary.total, count)
console.log(`PASS SQL: snapshot, rotation, real repeats, concurrency, restart-safe state, cursor/SSE, filters, context, retention, deletion; ${count} events inserted in ${Math.round(readStarted - started)}ms; history+summary ${Math.round(performance.now() - readStarted)}ms`)
} finally {
await new Promise<void>(resolve => router.close(() => resolve()))
await pool.end()
await admin.query(`DROP SCHEMA ${schema} CASCADE`)
await admin.end()
}
+17
View File
@@ -49,6 +49,7 @@ import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import { collectGeoipUpdateOnce } from "./geoip-update-collector.js"
import { getGeoipSettings } from "./geoip-settings.js"
import { collectRouterLogsOnce, getLogSettings, startLogsLiveTimer, stopLogsLiveTimer } from "./router-logs-collector.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -61,6 +62,7 @@ import { appendEvent } from "../modules/events/service/events-service.js"
import { runRecordedSchedulerJob, type SchedulerRunReceipt } from "./scheduler-recorded-run.js"
export const JOB_KEYS = [
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -81,6 +83,7 @@ let schedulerGeneration = 0
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -130,6 +133,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<SchedulerRu
collect: async () => {
let snapshot: SchedulerRunSnapshot | undefined
switch (jobKey) {
case "router_logs":
snapshot = await collectRouterLogsOnce()
break
case "traffic":
snapshot = await collectTrafficOnce()
break
@@ -221,6 +227,7 @@ export async function runSchedulerJobNow(jobKey: string): Promise<SchedulerRunRe
}
function clearAllTimers() {
stopLogsLiveTimer()
clearAlertCollectorHooksTimer()
for (const t of timers.values()) clearInterval(t)
timers.clear()
@@ -233,6 +240,14 @@ export async function refreshScheduler(): Promise<void> {
const superseded = () => generation !== schedulerGeneration || isSchedulerJobsPaused()
clearAllTimers()
const logs = await getLogSettings()
if (superseded()) return
startLogsLiveTimer()
if (logs.enabled) {
void executeSchedulerJob("router_logs")
timers.set("router_logs", setInterval(() => { void executeSchedulerJob("router_logs") }, logs.intervalSec * 1000))
}
const traffic = await getTrafficSettings()
if (superseded()) return
if (traffic.enabled) {
@@ -398,6 +413,7 @@ async function lastRunForJob(jobKey: string) {
}
export async function getSchedulerStatus() {
const logs = await getLogSettings()
const traffic = await getTrafficSettings()
const uptime = await getUptimeSettings()
const apiPing = await getServersApiPingSettings()
@@ -411,6 +427,7 @@ export async function getSchedulerStatus() {
const spdOn = uptime.speedEnabled ?? uptime.enabled
const jobMeta: Record<SchedulerJobKey, { enabled: boolean; intervalSec: number }> = {
router_logs: { enabled: logs.enabled, intervalSec: logs.intervalSec },
traffic: { enabled: traffic.enabled, intervalSec: traffic.intervalSec },
servers_rest_ping: { enabled: apiPing.enabled, intervalSec: apiPing.intervalSec },
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
const socket = process.platform === "win32" ? `\\\\.\\pipe\\flow-agent-${randomUUID()}` : join(tmpdir(), `flow-agent-${randomUUID()}.sock`)
const snapshot: FlowAgentSnapshot = {
collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4739, wgListenPort: 51821,
hostPublicKey: "public-fixture", hostPrivateKey: "private-fixture", peers: [],
}
let mode = "ok"
const server = createServer((req, res) => {
let body = ""
req.on("data", chunk => { body += chunk })
req.on("end", () => {
assert.equal(req.url, "/check"); assert.equal(req.method, "POST")
assert.deepEqual(JSON.parse(body), snapshot)
if (mode === "conflict") { res.writeHead(409); res.end(JSON.stringify({ error: "Конфликт адресов" })); return }
if (mode === "invalid") { res.end("not-json"); return }
if (mode === "oversize") { res.end("x".repeat(300_000)); return }
res.end(JSON.stringify({
interfaceUp: true, configMatches: true, publicKey: "public-fixture", listenPort: 51821,
addresses: ["10.255.254.1/24"], peers: [{ publicKey: "router-fixture", allowedIps: ["10.255.254.2/32"], lastHandshakeAt: null, receivedBytes: 0, sentBytes: 0 }],
privateKey: "must-not-be-exposed",
}))
})
})
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(socket, resolve) })
try {
const status = await callFlowAgent("/check", snapshot, socket)
assert.equal(status.reachable, true); assert.equal(status.configMatches, true)
assert.equal(JSON.stringify(status).includes("must-not-be-exposed"), false)
assert.equal(status.peers[0].lastHandshakeAt, null)
mode = "conflict"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /Конфликт адресов/)
mode = "invalid"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
mode = "oversize"; await assert.rejects(callFlowAgent("/check", snapshot, socket), /некорректный ответ/)
await assert.rejects(callFlowAgent("/status", undefined, ""), /не настроена/)
assert.equal(emptyHostStatus(true, "offline").configMatches, null)
} finally { await new Promise<void>(resolve => server.close(() => resolve())) }
console.log("NetFlow agent IPC: request, validated status, secret stripping, conflict, invalid/oversize response, manual mode")
@@ -0,0 +1,45 @@
import { request } from "node:http"
import { trafficFlowHostStatusSchema, type TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export type FlowAgentSnapshot = {
collectorIp: string; prefix: string; flowListenPort: number; wgListenPort: number
hostPublicKey: string; hostPrivateKey: string
peers: { serverId: number; publicKey: string; allowedIps: string[]; endpoint?: string }[]
}
export const agentSocket = () => process.env.FLOW_AGENT_SOCKET?.trim() || ""
export const emptyHostStatus = (configured: boolean, error: string | null = null): TrafficFlowHostStatus => ({
configured, reachable: false, interfaceUp: false, configMatches: null,
publicKey: "", listenPort: 0, addresses: [], peers: [], error,
})
export function callFlowAgent(path: "/status" | "/check" | "/apply", snapshot?: FlowAgentSnapshot, socketPath = agentSocket()): Promise<TrafficFlowHostStatus> {
if (!socketPath) return Promise.reject(new Error("Автоматизация хоста NetFlow не настроена"))
const body = snapshot ? JSON.stringify(snapshot) : undefined
return new Promise((resolve, reject) => {
const fail = () => reject(new Error("Агент NetFlow недоступен или вернул некорректный ответ; проверьте контейнер mmapp-netflow-agent"))
const req = request({ socketPath, path, method: body ? "POST" : "GET", headers: body ? { "content-type": "application/json", "content-length": Buffer.byteLength(body) } : {} }, res => {
let text = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { text += chunk; if (text.length > 262144) req.destroy() })
res.on("error", fail)
res.on("end", () => {
try {
const raw: unknown = JSON.parse(text)
if (res.statusCode !== 200) {
// The local controller deliberately emits sanitized errors only.
const error = raw as { error?: unknown }
reject(new Error(typeof error.error === "string" ? error.error.slice(0, 500) : "Агент NetFlow отклонил настройки"))
return
}
resolve(trafficFlowHostStatusSchema.parse({ ...(raw as object), configured: true, reachable: true, error: null }))
} catch { fail() }
})
})
// An absolute deadline also covers a peer that keeps sending small chunks.
const timer = setTimeout(() => req.destroy(), path === "/apply" ? 90_000 : 30_000)
req.on("close", () => clearTimeout(timer))
req.on("error", fail)
req.end(body)
})
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
const files = buildTrafficFlowHostFiles({ collectorIp: "10.255.254.1", prefix: "10.255.254.0/24", flowListenPort: 4740,
wgListenPort: 51821, hostPrivateKey: Buffer.alloc(32, 1).toString("base64"), peers: [] })
const conf = files.find(file => file.id === "wg-quick")!.code
const source = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const script = buildFlowAgentInstaller(conf, 4740, source)
assert.ok(script.startsWith("#!/usr/bin/env bash\n"))
assert.ok(script.includes(conf.trim() + "\nMM_CONFIG_EOF\n"))
assert.ok(script.includes('"${1:-/opt/cdn-mm}" 4740'))
assert.ok(script.includes("[email protected]"))
assert.ok(script.includes("docker-compose.netflow-agent.yml"))
assert.ok(!script.includes("\r"))
assert.throws(() => buildFlowAgentInstaller("MM_CONFIG_EOF\n", 4739, source))
assert.throws(() => buildFlowAgentInstaller(conf, 0, source))
console.log("NetFlow agent installer export: validated snapshot, custom port/path, embedded installer and LF-only output")
@@ -0,0 +1,8 @@
/** Call only with a configuration already validated by buildTrafficFlowHostFiles. */
export function buildFlowAgentInstaller(conf: string, flowPort: number, installer: string): string {
if (!Number.isInteger(flowPort) || flowPort < 1 || flowPort > 65535 || conf.split(/\r?\n/).includes("MM_CONFIG_EOF")) {
throw new Error("Некорректная конфигурация установщика NetFlow")
}
const clean = (text: string) => text.replaceAll("\r\n", "\n").replace(/\s*$/, "") + "\n"
return "#!/usr/bin/env bash\nset -euo pipefail\numask 077\nEXPORT_DIR=$(mktemp -d)\ntrap 'rm -rf -- \"$EXPORT_DIR\"' EXIT\ncat > \"$EXPORT_DIR/wg-flow.conf\" <<'MM_CONFIG_EOF'\n" + clean(conf) + "MM_CONFIG_EOF\ncat > \"$EXPORT_DIR/install.sh\" <<'MM_INSTALL_EOF'\n" + clean(installer) + "MM_INSTALL_EOF\nbash \"$EXPORT_DIR/install.sh\" \"$EXPORT_DIR/wg-flow.conf\" \"${1:-/opt/cdn-mm}\" " + flowPort + "\n"
}
@@ -4,7 +4,7 @@ import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters, resetFlowAnalyticsCacheForTests } from "./traffic-flow-analytics.js"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
@@ -556,6 +556,22 @@ try {
}
}
// Historical 5m/15m charts survive an empty live ring (e.g. immediately after restart).
resetFlowRingsForTests()
await dbQuery("DELETE FROM flow_buckets WHERE server_id=$1", [serverId])
for (const minutesAgo of [2, 3, 4, 10]) {
await dbQuery(`INSERT INTO flow_buckets (server_id,bucket_at,src,dst,in_iface,bytes,packets)
VALUES ($1,date_trunc('minute',now())-($2::int*interval '1 minute'),'10.1.1.8','8.8.8.8','2',60000,10)`, [serverId, minutesAgo])
}
for (const minutes of [5, 15]) for (const dedup of [true, false]) {
resetFlowAnalyticsCacheForTests()
const history = await buildFlowAnalytics({ minutes, serverId, dedup, excludeMesh:false, excludeOverlay:false })
assert.equal(history.rxSeries.length, minutes)
assert.equal(history.rxSeries.filter(v => v > 0).length, minutes === 5 ? 3 : 4)
const missing = await buildFlowAnalytics({ minutes, serverId, iface:"not-an-interface" })
assert(missing.rxSeries.every(v => v === 0), "Historical chart respects interface filter")
}
} finally {
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
@@ -423,9 +423,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
// The live ring covers about one minute; historical charts must use the requested window.
const fromBuckets = seriesFromRows(matched, q.minutes)
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
const rxSeries = fromBuckets.rx
const txSeries = fromBuckets.tx
const ifaceRows = [...ifacesMap.entries()]
.sort((a, b) => b[1].bytes - a[1].bytes)
@@ -0,0 +1,46 @@
import { withAdvisoryLock } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { getTrafficFlowSettingsRow, invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
import { agentSocket, callFlowAgent, emptyHostStatus, type FlowAgentSnapshot } from "./traffic-flow-agent-client.js"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
// Session lock: RouterOS/network operations use other pooled connections. Never lock the settings row here.
const queue = new FlowConfigurationQueue()
export const withFlowConfigurationLock = <T>(fn: () => Promise<T>) => queue.run(() => withAdvisoryLock(7340291, async () => {
invalidateTrafficFlowSettingsCache()
return fn()
}))
async function snapshot(): Promise<FlowAgentSnapshot> {
const row = await getTrafficFlowSettingsRow()
return {
collectorIp: row.collectorIp, prefix: row.prefix, flowListenPort: row.flowListenPort, wgListenPort: row.wgListenPort,
hostPublicKey: row.hostPublicKey, hostPrivateKey: row.hostPrivateKey,
peers: (parseJsonArray(row.peersJson) as FlowAgentSnapshot["peers"]).map(p => ({
serverId: p.serverId, publicKey: p.publicKey, allowedIps: p.allowedIps, ...(p.endpoint ? { endpoint: p.endpoint } : {}),
})),
}
}
export async function getFlowHostStatus() {
if (!agentSocket()) return emptyHostStatus(false)
try { return await callFlowAgent("/check", await snapshot()) }
catch (error) {
const message = error instanceof Error ? error.message : "Проверка агента не выполнена"
// Preserve live measurements on a settings conflict; never claim configuration agreement.
try { return { ...await callFlowAgent("/status"), configMatches: false, error: message } }
catch { return emptyHostStatus(true, message) }
}
}
export async function preflightFlowHost() {
if (!agentSocket()) return
try { await callFlowAgent("/check", await snapshot()) }
catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : "Агент NetFlow не готов"), { statusCode: 409 }) }
}
// Caller holds withFlowConfigurationLock. This is also used after saving a newly discovered router key.
export async function reconcileFlowHost() {
if (!agentSocket()) return emptyHostStatus(false)
return callFlowAgent("/apply", await snapshot())
}
@@ -1,8 +1,15 @@
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
import { parseJsonArray } from "../db/json.js"
import { buildTrafficFlowHostFiles } from "./traffic-flow-host-file-builders.js"
import { readFile } from "node:fs/promises"
import { buildFlowAgentInstaller } from "./traffic-flow-agent-install.js"
export async function listTrafficFlowHostFiles() {
const row = await getTrafficFlowSettingsRow()
return buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
const files = buildTrafficFlowHostFiles({ ...row, peers: parseJsonArray(row.peersJson) })
// The same source is used by a repository install and by the downloadable self-contained installer.
const installer = await readFile(new URL("../../../deploy/netflow-agent/install.sh", import.meta.url), "utf8")
const conf = files.find(file => file.id === "wg-quick")!.code
const code = buildFlowAgentInstaller(conf, row.flowListenPort, installer)
return [{ id: "agent", label: "Агент Docker", filename: "install-netflow-agent.sh", code }, ...files]
}
+1 -1
View File
@@ -439,7 +439,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
// History remains available while the collector starts or is stopped; pending rows are merged below.
return await listStoredFlowRows(sinceIso)
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { FlowConfigurationQueue } from "./traffic-flow-operation-queue.js"
const queue = new FlowConfigurationQueue()
let release!: () => void, active = 0, maximum = 0
const hold = new Promise<void>(resolve => { release = resolve })
const events: number[] = []
const tasks = Array.from({ length: 32 }, (_, i) => queue.run(async () => {
active++; maximum = Math.max(active, maximum); events.push(i)
try { if (i === 0) await hold; if (i === 1) throw Error("fixture failure"); return i }
finally { active-- }
}))
const done = Promise.allSettled(tasks)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(events, [0]); assert.equal(active, 1)
release()
const results = await done
assert.equal(maximum, 1); assert.equal(results[1].status, "rejected")
assert.equal(results.filter(result => result.status === "fulfilled").length, 31)
assert.deepEqual(events, Array.from({ length: 32 }, (_, i) => i))
assert.equal(await queue.run(async () => "recovered"), "recovered")
console.log("NetFlow queue: 32 concurrent operations use one lock waiter, failures release the queue")
@@ -0,0 +1,12 @@
/** Only one local operation may wait for the PostgreSQL advisory lock.
* Otherwise queued requests could exhaust the pool needed by its current owner.
*/
export class FlowConfigurationQueue {
private tail: Promise<void> = Promise.resolve()
run<T>(operation: () => Promise<T>): Promise<T> {
const result = this.tail.then(operation)
this.tail = result.then(() => undefined, () => undefined)
return result
}
}
+29 -3
View File
@@ -22,6 +22,8 @@ import {
import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
import { agentSocket } from "./traffic-flow-agent-client.js"
import { getFlowHostStatus, preflightFlowHost, reconcileFlowHost, withFlowConfigurationLock } from "./traffic-flow-host-agent.js"
const IFACE_NAME = "wg-flow"
const JH_LISTEN_PORT = 13232
@@ -202,8 +204,16 @@ export function usablePublicHost(raw: string | undefined): string {
export async function applyFlowOverlay(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return withFlowConfigurationLock(() => applyFlowOverlayLocked(serverIdRaw, opts))
}
async function applyFlowOverlayLocked(
serverIdRaw: string | number,
opts?: { publicEndpoint?: string; requestHost?: string; disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
await preflightFlowHost()
const keys = await ensureHostKeys()
const settings = await getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
@@ -338,8 +348,21 @@ export async function applyFlowOverlay(
await refreshFlowExporterMap()
steps.push("Коллектор IPFIX на MM включён")
let hostStatus = undefined
let hostError: string | undefined
if (agentSocket()) {
try {
hostStatus = await reconcileFlowHost()
steps.push("Пир применён на Linux-хосте без перезапуска WireGuard")
} catch (error) {
hostError = error instanceof Error ? error.message : "Настройки хоста не применены"
hostStatus = await getFlowHostStatus()
steps.push("MikroTik настроен; требуется повторное применение настроек хоста")
}
}
return {
ok: true,
ok: !hostError,
serverId: server.id,
interfaceName: IFACE_NAME,
address,
@@ -347,11 +370,14 @@ export async function applyFlowOverlay(
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: await listTrafficFlowHostFiles(),
hostFiles: agentSocket() ? [] : await listTrafficFlowHostFiles(),
stage: hostError ? "host-pending" : agentSocket() ? "applied" : "manual",
...(hostStatus ? { hostStatus } : {}),
...(hostError ? { hostError } : {}),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
const err = Object.assign(new Error(`RouterOS: ${msg}`), { statusCode: 502 })
const err = Object.assign(new Error(`Настройка MikroTik могла быть выполнена частично: ${msg}. Проверьте состояние и повторите подключение.`), { statusCode: 502 })
throw err
}
}
+26 -1
View File
@@ -132,7 +132,7 @@ const observed=(rows:ReturnType<typeof observationSample>[])=>buildTrafficFromSa
assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false)
assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false)
const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)])
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,1)
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,30)
const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)])
assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true)
const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)])
@@ -147,3 +147,28 @@ console.log("traffic observation metadata tests ok")
const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true)
const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false)
assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null)
// A 30-second collector must produce a line across measured intervals on short ranges.
for (const minutes of [5, 15]) {
const span = minutes * 60_000
const rows = Array.from({ length: span / 30_000 + 1 }, (_, i) => ({
...observationSample(i * 30_000, i * 3_750_000, i * 1_875_000),
continuityKnown: true, nativeCounterIdentity: "ether1-v1",
}))
const history = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert(history.observations!.rxSeries.every(Boolean), `${minutes}m continuous measured RX`)
assert(history.observations!.txSeries.every(Boolean), `${minutes}m continuous measured TX`)
assert(history.rxSeries.every(v => v === 1)); assert(history.txSeries.every(v => v === 0.5))
assert.equal(history.rxTotalGiB, (span / 30_000) * 3_750_000 / 1024 ** 3)
// Explicit loss of continuity stays unknown; it must not be bridged by chart rendering.
rows[2].continuityKnown = false
const gap = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert.equal(gap.observations!.rxSeries.filter(v => !v).length, 30_000 / (span / 60))
assert(gap.observations!.rxSeries.slice(0, 30_000 / (span / 60)).every(Boolean))
const partial = buildTrafficFromSamples(rows.slice(4), observationStart, observationStart + span, undefined, undefined, true)
assert(partial.observations!.rxSeries.slice(0, 120_000 / (span / 60)).every(v => !v), "No history invented before first sample")
}
// Unequal poll intervals contribute by covered duration when sharing a chart bucket.
const weighted = observed([observationSample(0,0,0),observationSample(500,62_500,62_500),observationSample(1500,312_500,312_500)])
assert.equal(weighted.rxSeries[0], 1.5)
assert.equal(weighted.rxSeries[1], 2)
+34 -6
View File
@@ -114,6 +114,34 @@ function parseIsoMs(iso: string): number {
return Date.parse(iso)
}
/** A counter delta measures its entire interval, not just the next poll's timestamp. */
function intervalSeries(
waves: Map<number, PeakWave>, start: number, end: number, side: "rx" | "tx",
): { values: number[]; observed: boolean[] } {
const sums = Array(SERIES_POINTS).fill(0) as number[]
const durations = Array(SERIES_POINTS).fill(0) as number[]
const unknown = Array(SERIES_POINTS).fill(false) as boolean[]
const width = (end - start) / SERIES_POINTS
if (!(width > 0)) return { values: sums, observed: unknown }
for (const [at, wave] of waves) {
const from = Math.max(start, wave.start), to = Math.min(end, at)
if (!(to > from)) continue
const first = Math.max(0, Math.floor((from - start) / width))
const last = Math.min(SERIES_POINTS - 1, Math.ceil((to - start) / width) - 1)
for (let i = first; i <= last; i++) {
const overlap = Math.min(to, start + (i + 1) * width) - Math.max(from, start + i * width)
if (!(overlap > 0)) continue
sums[i] += wave[side] * overlap
durations[i] += overlap
unknown[i] ||= !wave[side === "rx" ? "rxKnown" : "txKnown"]
}
}
return {
values: sums.map((sum, i) => durations[i] > 0 ? sum / durations[i] : 0),
observed: durations.map((duration, i) => duration > 0 && !unknown[i]),
}
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
@@ -232,8 +260,6 @@ export function buildTrafficFromSamples(
const latestWave = membership.get(latestMembershipAt)
if (latestWave?.declared) sessions = latestWave.active
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
const last = lastTs != null ? byTs.get(lastTs) : undefined
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
@@ -249,6 +275,8 @@ export function buildTrafficFromSamples(
const complete = wave.sameStart && wave.count === expectedAt(t)
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
}
const rxHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "rx")
const txHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "tx")
return retainPeakWaves({
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
@@ -257,13 +285,13 @@ export function buildTrafficFromSamples(
rxTotalGiB: rxBytesDelta / (1024 ** 3),
txTotalGiB: txBytesDelta / (1024 ** 3),
sessions,
rxSeries,
txSeries,
rxSeries: rxHistory.values,
txSeries: txHistory.values,
...(includeObservations?{observations:{
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
rxSeries:rxHistory.observed,
txSeries:txHistory.observed,
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
rxPeak:[...waves.values()].some(w=>w.rxKnown),
+1
View File
@@ -10,6 +10,7 @@ export function withPgOrSkip(): Promise<boolean> {
await initDatabase()
return true
} catch (err) {
if (process.env.REQUIRE_TEST_POSTGRES === "1") throw err
console.warn(
"skip: PostgreSQL недоступен",
err instanceof Error ? err.message : err,
@@ -245,7 +245,17 @@ export interface GeoipUpdateRunSnapshot {
errors: string[]
}
export interface RouterLogsRunSnapshot {
v: 1
job: "router_logs"
sampledAt: string
skipped?: boolean
errors: string[]
servers: { serverId: number; name: string; added: number; gap: boolean; error?: string }[]
}
export type SchedulerRunSnapshot =
| RouterLogsRunSnapshot
| TrafficRunSnapshot
| ResourcesRunSnapshot
| PingRunSnapshot
+1 -1
View File
@@ -77,7 +77,7 @@ function FilterGatewayCell({
? recursiveRoutes.find((r) => r.id === rule.gatewayTunnelId.slice(4))
: undefined
const recRowByHop =
!isBlackhole && !(rule.gatewayTunnelId ?? "").trim() && rule.gateway.trim()
!isBlackhole && !recRowByRef && (isRecRef || !(rule.gatewayTunnelId ?? "").trim()) && rule.gateway.trim()
? pickRecursiveRouteByGatewayHop(recursiveRoutes, rule.gateway)
: undefined
const recRow = recRowByRef ?? recRowByHop
+53
View File
@@ -0,0 +1,53 @@
"use client"
// Adapted from ReUI PRO sheet-14: event header, facts, tabs and stepping context.
// https://reui.io/preview/base/sheet-14
import { useEffect, useState } from "react"
import Link from "next/link"
import { z } from "zod"
import type { LogEntry } from "@mmapp/contracts"
import { toast } from "sonner"
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetHeader } from "@/components/ui/sheet"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { getLogContext } from "@/shared/api/logs"
import { LevelBadge, formatLogTime } from "./log-presentation"
export function LogDetailSheet({ entry, base, onClose, onSelect }: { entry: LogEntry | null; base: string; onClose: () => void; onSelect: (entry: LogEntry) => void }) {
return <Sheet open={!!entry} onOpenChange={open => { if (!open) onClose() }}><SheetContent className="data-[side=right]:w-full data-[side=right]:sm:max-w-2xl overflow-y-auto">
{entry && <LogDetail key={entry.id} entry={entry} base={base} onSelect={onSelect} />}
</SheetContent></Sheet>
}
function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) {
const [context, setContext] = useState<Awaited<ReturnType<typeof getLogContext>> | null>(null), [error, setError] = useState<string | null>(null), [retry, setRetry] = useState(0)
useEffect(() => {
const ac = new AbortController()
void getLogContext(base, entry.id, ac.signal).then(data => { if (!ac.signal.aborted) { setContext(data); setError(null) } }).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный ответ контекста" : e.message) })
return () => ac.abort()
}, [base, entry.id, retry])
const copy = () => { void navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => toast.success("Событие скопировано"), () => toast.error("Не удалось скопировать событие")) }
return <>
<SheetHeader className="gap-3 border-b">
<div className="flex flex-wrap items-center gap-2"><LevelBadge level={entry.level} /><span className="text-xs text-muted-foreground font-mono">{entry.routerId}</span></div>
<SheetTitle>Событие RouterOS</SheetTitle><SheetDescription>{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)}</SheetDescription>
<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" onClick={copy}><CopyIcon />Копировать</Button><Button size="sm" variant="outline" nativeButton={false} render={<Link href={`/servers?server=${entry.serverId}`} />}><ExternalLinkIcon />Устройство</Button></div>
</SheetHeader>
<Tabs defaultValue="details" className="min-w-0 px-4 pb-4">
<TabsList variant="line"><TabsTrigger value="details">Событие</TabsTrigger><TabsTrigger value="raw">Исходные поля</TabsTrigger><TabsTrigger value="context">Контекст</TabsTrigger></TabsList>
<TabsContent value="details" className="flex flex-col gap-4">
<p className="whitespace-pre-wrap break-words font-mono text-sm">{entry.message}</p>
<dl className="grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
{[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => <FragmentPair key={label} label={label} value={value} />)}
</dl>
</TabsContent>
<TabsContent value="raw"><pre className="overflow-auto whitespace-pre-wrap break-all text-xs font-mono">{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}</pre></TabsContent>
<TabsContent value="context" className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.</p>
{error ? <Alert variant="destructive"><AlertTitle>Контекст не получен</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !context ? <Skeleton className="h-32 w-full" /> : [context.before, [context.entry], context.after].flat().map(row => <Button key={row.id} variant="ghost" className="h-auto justify-start whitespace-normal text-left" onClick={() => onSelect(row)} aria-current={row.id === entry.id ? "true" : undefined}><span className="flex min-w-0 flex-col gap-1"><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.effectiveAt)} · {row.topics.join(", ")}</span><span className="break-all font-mono text-xs">{row.message}</span></span></Button>)}
</TabsContent>
</Tabs>
</>
}
function FragmentPair({ label, value }: { label: string; value: string }) { return <><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words">{value}</dd></> }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import { Fragment } from "react"
import { AlertTriangleIcon, CircleAlertIcon, CircleHelpIcon, InfoIcon, BugIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import type { LogLevel } from "@mmapp/contracts"
export const LEVEL_LABELS: Record<LogLevel, string> = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
export const LEVEL_COLORS: Record<LogLevel, string> = { critical: "var(--destructive)", error: "var(--destructive)", warning: "var(--warning)", info: "var(--info)", debug: "var(--muted-foreground)", unknown: "var(--border)" }
export function LevelBadge({ level }: { level: LogLevel }) {
const Icon = level === "critical" || level === "error" ? CircleAlertIcon : level === "warning" ? AlertTriangleIcon : level === "debug" ? BugIcon : level === "unknown" ? CircleHelpIcon : InfoIcon
return <Badge variant={level === "critical" || level === "error" ? "destructive-light" : level === "warning" ? "warning-light" : level === "info" ? "info-light" : "secondary"} size="sm"><Icon aria-hidden="true" className="size-3" />{LEVEL_LABELS[level]}</Badge>
}
export function HighlightMessage({ text, search }: { text: string; search: string }) {
if (!search) return <>{text}</>
const lower = text.toLocaleLowerCase(), needle = search.toLocaleLowerCase(), parts = []
let start = 0, found = lower.indexOf(needle)
while (found >= 0) {
parts.push(<Fragment key={start}>{text.slice(start, found)}<mark className="bg-primary/15 text-foreground">{text.slice(found, found + search.length)}</mark></Fragment>)
start = found + search.length; found = lower.indexOf(needle, start)
}
return <>{parts}{text.slice(start)}</>
}
export function formatLogTime(at: string | null) { return at ? new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—" }
@@ -0,0 +1,54 @@
"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { type LogSettings, type LogSummary } from "@mmapp/contracts"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Skeleton } from "@/components/ui/skeleton"
import { getLogSettings, getLogSummary, putLogSettings } from "@/shared/api/logs"
import { can, ensureAuthConfig } from "@/lib/auth"
import { formatLogTime } from "./log-presentation"
export function LogsCollectorSettings({ base, onSaved }: { base: string; onSaved?: (settings: LogSettings) => void }) {
const [settings, setSettings] = useState<LogSettings | null>(null), [summary, setSummary] = useState<LogSummary | null>(null)
const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
const [error, setError] = useState<string | null>(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
const lifetime = useRef<AbortController | null>(null), busy = useRef(false)
useEffect(() => {
const ac = new AbortController(); lifetime.current = ac
void ensureAuthConfig().then(async () => {
if (!can("mm:settings:admin")) throw new Error("Настройки логов доступны с mm:settings:admin")
const data = await getLogSettings(base, ac.signal)
if (ac.signal.aborted) return
setSettings(data); setEnabled(data.enabled); setIntervalDraft(String(data.intervalSec)); setRetention(String(data.retentionDays)); setError(null)
const stats = await getLogSummary(base, new URLSearchParams({ from: new Date(Date.now() - 900000).toISOString() }), ac.signal)
if (!ac.signal.aborted) setSummary(stats)
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof Error ? e.message : String(e)) })
return () => ac.abort()
}, [base, revision])
const save = async () => {
if (busy.current || !settings || !can("mm:settings:admin")) return
if (!/^\d+$/.test(interval) || !/^\d+$/.test(retention)) { setError("Укажите целые значения интервала и срока хранения"); return }
if (Number(interval) < 5 || Number(interval) > 3600 || Number(retention) < 1 || Number(retention) > 365) { setError("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней"); return }
busy.current = true; setSaving(true); setNotice("")
const signal = lifetime.current?.signal
try {
const confirmed = await putLogSettings(base, { enabled, intervalSec: Number(interval), retentionDays: Number(retention) }, signal)
if (signal?.aborted) return
setSettings(confirmed); setError(null); setNotice("Настройки сборщика логов сохранены"); onSaved?.(confirmed)
} catch (e) { if (!signal?.aborted) setError(e instanceof Error ? e.message : String(e)) }
finally { busy.current = false; if (!signal?.aborted) setSaving(false) }
}
return <Frame id="router-logs" dense><FrameHeader><FrameTitle>Логи MikroTik</FrameTitle><FrameDescription>REST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.</FrameDescription></FrameHeader><FramePanel className="flex flex-col gap-4">
{error && <Alert variant="destructive"><AlertTitle>Данные сборщика не подтверждены</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" disabled={saving} onClick={() => setRevision(n => n + 1)}>Повторить загрузку</Button></AlertDescription></Alert>}
{!settings && !error && <Skeleton className="h-24 w-full" />}
<div className="flex flex-wrap items-end gap-4"><label className="flex items-center gap-2 text-sm"><Switch checked={enabled} onCheckedChange={setEnabled} disabled={!settings || saving} aria-label="Включить сбор логов" />Собирать логи</label><label className="flex flex-col gap-1 text-xs">Фоновый интервал, секунд<Input aria-label="Интервал сбора логов" className="w-40" inputMode="numeric" value={interval} onChange={e => setIntervalDraft(e.target.value)} disabled={!settings || saving} /></label><label className="flex flex-col gap-1 text-xs">Хранить, дней<Input aria-label="Срок хранения логов" className="w-40" inputMode="numeric" value={retention} onChange={e => setRetention(e.target.value)} disabled={!settings || saving} /></label><Button size="sm" disabled={!settings || saving} onClick={() => void save()}>{saving ? "Сохранение…" : "Сохранить настройки логов"}</Button></div>
{notice && <p role="status" className="text-sm">{notice}</p>}
<p className="text-xs text-muted-foreground">Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.</p>
{summary?.states.length ? <div className="flex flex-col gap-2">{summary.states.map(state => <div key={state.serverId} className="flex flex-wrap items-center gap-2 border-t py-2 text-xs"><Link href={`/logs?server=${state.serverId}`} className="font-medium underline">{state.serverName}</Link><Badge size="sm" variant={state.lastError ? "warning-light" : state.lastCollectedAt ? "success-light" : "secondary"}>{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}</Badge><span className="text-muted-foreground">{formatLogTime(state.lastCollectedAt)}</span>{state.gapAt && <span>Возможный разрыв: {formatLogTime(state.gapAt)}</span>}{state.lastError && <span role="status" className="break-all">{state.lastError}</span>}</div>)}</div> : <p className="text-xs text-muted-foreground">Результатов сбора по устройствам пока нет.</p>}
</FramePanel></Frame>
}
+52
View File
@@ -0,0 +1,52 @@
"use client"
// ReUI PRO solution-dev-ops-2: Frame-led grid, toolbar and display settings.
// https://reui.io/preview/base/solution-dev-ops-2
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useTable, type ColumnDef } from "@tanstack/react-table"
import { elementScroll, observeElementRect, observeElementOffset } from "@tanstack/react-virtual"
import type { LogEntry } from "@mmapp/contracts"
import { CopyIcon, Columns3Icon, WrapTextIcon } from "lucide-react"
import { toast } from "sonner"
import { DataGrid, DataGridContainer, DataGridTableVirtual, DataGridColumnVisibility, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
import { HighlightMessage, LevelBadge, formatLogTime } from "./log-presentation"
export function LogsGrid({ entries, loading, total, search, toolbar, onSelect, onPause, hasMore, fetchingMore, loadMore, reset }: {
entries: LogEntry[]; loading: boolean; total: number | null; search: string; toolbar: ReactNode;
onSelect: (entry: LogEntry) => void; onPause: () => void; hasMore: boolean; fetchingMore: boolean; loadMore: () => void; reset: () => void
}) {
const [wrap, setWrap] = useState(false)
const [narrow, setNarrow] = useState(false)
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)")
const update = () => setNarrow(mq.matches)
queueMicrotask(update); mq.addEventListener("change", update)
return () => mq.removeEventListener("change", update)
}, [])
const columns = useMemo<ColumnDef<DataGridFeatures, LogEntry>[]>(() => [
{ accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => <span className="font-mono text-xs tabular-nums" title={row.original.timeKind === "receipt" ? `Время получения; RouterOS: ${row.original.rawTime}` : row.original.rawTime}>{formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && <span aria-label="Время получения"> *</span>}</span> },
{ accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => <span className="truncate" title={row.original.host}>{row.original.serverName}</span> },
{ accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => <LevelBadge level={row.original.level} /> },
{ accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => <span className="truncate text-xs text-muted-foreground font-mono" title={row.original.topics.join(", ")}>{row.original.topics.join(", ")}</span> },
{ accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => <Button variant="ghost" className="h-auto min-w-0 w-full justify-start whitespace-normal text-left" onClick={event => { event.stopPropagation(); onSelect(row.original) }} aria-label={`Открыть событие ${row.original.id}`}><span className="flex min-w-0 flex-col gap-1">{narrow && <span className="flex flex-wrap items-center gap-1"><LevelBadge level={row.original.level} /><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.original.effectiveAt)}</span></span>}<span className={`min-w-0 font-mono text-xs ${wrap || narrow ? "whitespace-pre-wrap break-words" : "truncate"}`}><HighlightMessage text={row.original.message} search={search} /></span></span></Button> },
{ id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => <Button size="icon-sm" variant="ghost" aria-label="Копировать сообщение" onClick={event => { event.stopPropagation(); void navigator.clipboard.writeText(row.original.message).then(() => toast.success("Сообщение скопировано"), () => toast.error("Не удалось скопировать сообщение")) }}><CopyIcon /></Button> },
], [wrap, narrow, search, onSelect])
const table = useTable({ features: dataGridFeatures, data: entries, columns, getRowId: row => row.id, manualPagination: true, rowCount: entries.length })
useEffect(() => { table.setColumnVisibility({ effectiveAt: !narrow, serverName: !narrow, level: !narrow, topics: !narrow, copy: !narrow }) }, [narrow, table])
return <DataGrid table={table} recordCount={entries.length} isLoading={loading} loadingMode="skeleton" i18n={{ labels: DATA_GRID_LABELS_RU }} onRowClick={onSelect}
tableLayout={{ dense: true, headerSticky: true, columnsVisibility: true, width: "fixed" }}
emptyMessage={<div className="flex flex-col items-center gap-2 p-6"><p>Событий за выбранный период нет</p><Button size="sm" variant="outline" onClick={reset}>Сбросить фильтры</Button></div>}
tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}>
<Frame dense className="min-w-0 w-full">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><FrameTitle>Журнал событий</FrameTitle><FrameDescription className="text-xs tabular-nums">Загружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}</FrameDescription></div><div className="flex items-center gap-2"><Button size="sm" variant="outline" aria-pressed={wrap} onClick={() => setWrap(v => !v)}><WrapTextIcon /><span className="max-sm:sr-only">Перенос</span></Button><DataGridColumnVisibility table={table} trigger={<Button variant="outline" size="sm"><Columns3Icon />Колонки</Button>} /></div></FrameHeader>
<FramePanel className="p-0"><div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">{toolbar}</div>
<div onScrollCapture={event => { if ((event.target as HTMLElement).scrollTop > 16) onPause() }}>
<DataGridContainer border={false}><DataGridTableVirtual height={420} estimateSize={48} overscan={8} hasMore={hasMore} isFetchingMore={fetchingMore} onFetchMore={loadMore} virtualizerOptions={{ scrollToFn: elementScroll, observeElementRect, observeElementOffset, measureElement: element => element.getBoundingClientRect().height }} /></DataGridContainer>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-xs text-muted-foreground"><span>* Использовано время получения. Часовой пояс отображения: браузер.</span>{hasMore && <Button size="sm" variant="outline" disabled={fetchingMore} onClick={loadMore}>{fetchingMore ? "Загрузка…" : "Загрузить ещё"}</Button>}</div>
</FramePanel>
</Frame>
</DataGrid>
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import Link from "next/link"
import { z } from "zod"
import { logFiltersSchema, logLevels, type LogEntry } from "@mmapp/contracts"
import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { DownloadIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, BookmarkPlusIcon, ActivityIcon } from "lucide-react"
import { toast } from "sonner"
import { PageHeader } from "@/components/page-header"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Filters } from "@/components/reui/filters/filters"
import { createFilterQuery, createFilterRule, flattenFilterConditions } from "@/components/reui/filters/filters-query"
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { useDataSource } from "@/lib/data-source"
import { can, ensureAuthConfig, getClaims } from "@/lib/auth"
import { firewallServersSchema } from "@/lib/firewall-response"
import { FILTER_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
import { requestJson } from "@/shared/api/http-client"
import { exportLogs } from "@/shared/api/logs"
import { useRouterLogs } from "@/hooks/use-router-logs"
import { LogsGrid } from "./logs-grid"
import { LogDetailSheet } from "./log-detail-sheet"
import { LEVEL_COLORS, LEVEL_LABELS, formatLogTime } from "./log-presentation"
const RANGES = [{ value: "15m", label: "15 минут" }, { value: "1h", label: "1 час" }, { value: "24h", label: "24 часа" }, { value: "7d", label: "7 дней" }, { value: "14d", label: "14 дней" }, { value: "custom", label: "Свой период" }]
const RANGE_MS: Record<string, number> = { "15m": 900000, "1h": 3600000, "24h": 86400000, "7d": 604800000, "14d": 1209600000 }
const PRESETS = [{ value: "", label: "Все события" }, { value: "errors", label: "Ошибки и critical" }, { value: "warnings", label: "Предупреждения" }, { value: "login-failure", label: "Ошибки входа · шаблон" }, { value: "interfaces", label: "Интерфейсы" }, { value: "routing", label: "Маршрутизация" }, { value: "firewall", label: "Firewall" }]
const viewSchema = z.array(z.object({ name: z.string().max(60), query: z.string().max(4000) })).max(20)
function navigate(values: Record<string, string | null>, replace = false) {
const params = new URLSearchParams(window.location.search)
for (const [key, value] of Object.entries(values)) { if (value) params.set(key, value); else params.delete(key) }
window.history[replace ? "replaceState" : "pushState"](null, "", "/logs?" + params)
}
export function LogsWorkspace() { return <Suspense fallback={<Skeleton className="h-64 w-full" />}><LogsRoute /></Suspense> }
function LogsRoute() {
const { backendUrl, mode, prefsHydrated } = useDataSource()
return prefsHydrated ? <LogsCatalog key={JSON.stringify([backendUrl, mode])} base={backendUrl} active={mode === "live"} /> : <p role="status" className="p-4">Загружаем источник…</p>
}
function LogsCatalog({ base, active }: { base: string; active: boolean }) {
const params = useSearchParams(), selectedId = params.get("server") ?? ""
const [servers, setServers] = useState<z.infer<typeof firewallServersSchema>>([]), [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false), [loading, setLoading] = useState(active), [retry, setRetry] = useState(0)
useEffect(() => {
const ac = new AbortController()
void ensureAuthConfig().then(async () => {
if (!active || ac.signal.aborted) return
if (!can("mm:servers:read")) throw new Error("Для чтения логов требуется mm:servers:read")
const rows = firewallServersSchema.parse(await requestJson(base, "/api/servers", { signal: ac.signal }))
if (ac.signal.aborted) return
setServers(rows); setReady(true); setError(null)
if (!new URLSearchParams(window.location.search).has("server")) navigate({ server: String(rows.find(s => s.enabled)?.id ?? ALL_SERVERS_ID) }, true)
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный каталог устройств" : e.message) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
return () => ac.abort()
}, [base, active, retry])
const items: ServerTileItem[] = servers.map(s => ({ id: String(s.id), name: s.name, host: s.host, country: s.country, type: s.type, status: s.status ?? "unknown", enabled: s.enabled, selectable: true }))
// Keep an explicit historical ID in the rail after deletion; history remains addressable.
if (selectedId && selectedId !== ALL_SERVERS_ID && !items.some(s => s.id === selectedId) && /^\d+$/.test(selectedId)) items.push({ id: selectedId, name: `История устройства #${selectedId}`, host: "Удалено или отсутствует в каталоге", enabled: false, selectable: true })
const selected = servers.find(s => String(s.id) === selectedId)
return <ServerRailLayout items={items} selectedId={selectedId} preserveSelection showAll allCount={servers.length} showCount={false} loading={loading} onSelect={id => navigate({ server: id, live: id === ALL_SERVERS_ID ? "0" : null })} contentClassName="min-w-0 min-h-0 overflow-y-auto p-2 md:p-3"
header={<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Логи" }]} actions={<><ServerRailMobileButton /><Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)} disabled={!active || loading}><RefreshCwIcon />Обновить устройства</Button></>} />}>
{!active ? <Alert variant="info"><AlertTitle>Демонстрационный источник</AlertTitle><AlertDescription>Для просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных.</AlertDescription></Alert> : error ? <Alert variant="destructive"><AlertTitle>Каталог устройств не подтверждён</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !ready || !selectedId ? <Skeleton className="h-80 w-full" /> : <LogExplorer key={JSON.stringify([base, params.toString()])} base={base} raw={params.toString()} selectedName={selected?.name ?? "История"} liveAvailable={!!selected?.enabled} />}
</ServerRailLayout>
}
function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string; raw: string; selectedName: string; liveAvailable: boolean }) {
const params = useMemo(() => new URLSearchParams(raw), [raw])
const [anchor] = useState(() => Date.now())
const range = params.get("range") ?? "15m", server = params.get("server") ?? ALL_SERVERS_ID
const live = server !== ALL_SERVERS_ID && liveAvailable && params.get("live") !== "0" && range !== "custom"
const parsed = useMemo(() => {
const from = range === "custom" ? params.get("from") : new Date(anchor - (RANGE_MS[range] ?? 900000)).toISOString()
return logFiltersSchema.safeParse({ serverId: server === ALL_SERVERS_ID ? undefined : server, from: from || undefined, to: range === "custom" ? params.get("to") || undefined : live ? undefined : new Date(anchor).toISOString(), levels: params.get("levels") ?? "", topics: params.get("topics") ?? "", q: params.get("q") ?? "", preset: params.get("preset") ?? "" })
}, [params, server, range, anchor, live])
const query = useMemo(() => { const query = new URLSearchParams(); if (parsed.success) for (const [key, value] of Object.entries(parsed.data)) if (value !== undefined && value !== "") query.set(key, String(value)); return query.toString() }, [parsed])
const [paused, setPaused] = useState(false), [selected, setSelected] = useState<LogEntry | null>(null)
const valid = parsed.success && RANGES.some(r => r.value === range) && (range !== "custom" || !!params.get("from") && !!params.get("to"))
const result = useRouterLogs(base, query, valid, live, paused || !!selected, live ? RANGE_MS[range] ?? 900000 : 0)
const [search, setSearch] = useState(params.get("q") ?? "")
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createFilterQuery(["levels", "topics"].flatMap(field => params.get(field) ? [createFilterRule({ id: field, path: [field], operator: "is_any_of", value: params.get(field)!.split(",") })] : [])))
const [viewName, setViewName] = useState(""), [views, setViews] = useState<z.infer<typeof viewSchema>>([]), [exporting, setExporting] = useState(false)
const exportController = useRef<AbortController | null>(null)
const storageKey = `mm:logs:views:${base}:${getClaims()?.sub ?? "local"}`
useEffect(() => { queueMicrotask(() => { try { setViews(viewSchema.parse(JSON.parse(localStorage.getItem(storageKey) ?? "[]"))) } catch { setViews([]) } }); return () => exportController.current?.abort() }, [storageKey])
const fields: FilterField[] = useMemo(() => [
{ id: "levels", label: "Уровень", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: logLevels.map(value => ({ value, label: LEVEL_LABELS[value] })) },
{ id: "topics", label: "Topics", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: [...new Set(["system", "account", "interface", "firewall", "route", "routing", "bgp", "ospf", "bfd", "dhcp", "dns", "wireguard", "ipsec", "script", ...result.entries.flatMap(row => row.topics), ...(params.get("topics")?.split(",") ?? [])])].sort().map(value => ({ value, label: value })) },
], [result.entries, params])
const applyFilters = (next: FilterQuery) => {
setFilterQuery(next)
const conditions = flattenFilterConditions(next), values: Record<string, string | null> = { levels: null, topics: null }
for (const condition of conditions) if (condition.values.length) values[condition.field] = condition.values.join(",")
navigate(values)
}
const reset = () => navigate({ q: null, levels: null, topics: null, preset: null, range: "15m", from: null, to: null })
const openEntry = useCallback((entry: LogEntry) => setSelected(entry), [])
const fetchMore = result.loadMore
const loadMore = useCallback(() => { setPaused(true); void fetchMore() }, [fetchMore])
const pause = useCallback(() => setPaused(true), [])
const state = result.stream?.state ?? result.summary?.states.find(s => String(s.serverId) === server)
const collectorEnabled = result.stream?.enabled ?? result.summary?.settings.enabled
const saveView = () => {
if (!viewName.trim()) return
const savedQuery = new URLSearchParams(raw); savedQuery.delete("server")
const next = [...views.filter(v => v.name !== viewName.trim()), { name: viewName.trim().slice(0, 60), query: savedQuery.toString() }].slice(-20)
try { localStorage.setItem(storageKey, JSON.stringify(next)); setViews(next); setViewName(""); toast.success("Представление сохранено") } catch { toast.error("Не удалось сохранить представление") }
}
const doExport = async (format: "csv" | "json") => {
const ac = new AbortController(); exportController.current = ac; setExporting(true)
const exportQuery = new URLSearchParams(query)
if (live) exportQuery.set("from", new Date(Date.now() - (RANGE_MS[range] ?? 900000)).toISOString())
try { await exportLogs(base, exportQuery, format, ac.signal) } catch (e) { if (!ac.signal.aborted) toast.error(e instanceof Error ? e.message : "Экспорт не завершён") } finally { if (!ac.signal.aborted) setExporting(false) }
}
if (!parsed.success || !RANGES.some(r => r.value === range) || range === "custom" && (!params.get("from") || !params.get("to"))) return <Alert variant="destructive"><AlertTitle>Некорректные фильтры в ссылке</AlertTitle><AlertDescription>Проверьте устройство, уровни и период.<Button size="sm" variant="outline" onClick={() => navigate({ server: ALL_SERVERS_ID, q: null, levels: null, topics: null, preset: null, range: null, from: null, to: null })}>Сбросить ссылку</Button></AlertDescription></Alert>
return <div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><h1 className="text-lg font-semibold tracking-tight">Логи MikroTik</h1><p className="text-sm text-muted-foreground">{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}</p></div><div className="flex flex-wrap items-center gap-2">
<Select value={range} items={RANGES} onValueChange={value => { if (!value) return; navigate(value === "custom" ? { range: value, from: new Date(anchor - 900000).toISOString(), to: new Date(anchor).toISOString(), live: "0" } : { range: value, from: null, to: null }) }}><SelectTrigger size="sm" aria-label="Период логов"><SelectValue /></SelectTrigger><SelectContent>{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}</SelectContent></Select>
<Button size="sm" variant={live ? "default" : "outline"} disabled={!liveAvailable || range === "custom"} onClick={() => navigate({ live: live ? "0" : null })}><ActivityIcon />{live ? "Live включён" : "История"}</Button><Button size="sm" variant="outline" onClick={result.reload}><RefreshCwIcon />Обновить</Button>
<Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("csv")}><DownloadIcon />CSV</Button><Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("json")}>JSON</Button>
</div></div>
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
<Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex flex-col gap-0.5"><FrameTitle>Поток событий</FrameTitle><FrameDescription>Выберите столбец, чтобы открыть его период</FrameDescription></div><div className="flex flex-wrap gap-2">{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => <span key={label} className="text-xs text-muted-foreground">{label}: <strong className="text-foreground tabular-nums">{count?.toLocaleString("ru-RU") ?? "—"}</strong></span>)}</div></FrameHeader><FramePanel>
{result.summaryError ? <Alert variant="warning"><AlertDescription>{result.summaryError}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert> : !result.summary ? <Skeleton className="h-32 w-full" /> : result.summary.buckets.length === 0 ? <div className="flex h-32 items-center justify-center text-sm text-muted-foreground">За выбранный период событий нет</div> : <ChartContainer className="h-36 w-full" config={Object.fromEntries(logLevels.map(level => [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}>
<BarChart accessibilityLayer data={result.summary.buckets} onClick={value => { const at = value.activeLabel; if (!at || !result.summary) return; const buckets = result.summary.buckets, i = buckets.findIndex(b => b.at === at); if (i < 0) return; const width = Math.max(1000, Math.ceil(((parsed.data.to ? Date.parse(parsed.data.to) : anchor) - Date.parse(parsed.data.from ?? new Date(anchor - 900000).toISOString())) / 60000) * 1000); navigate({ range: "custom", from: buckets[i].at, to: new Date(Date.parse(buckets[i].at) + width - 1).toISOString(), live: "0" }) }}><XAxis dataKey="at" tickFormatter={value => new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /><YAxis allowDecimals={false} width={32} tickLine={false} axisLine={false} /><ChartTooltip content={<ChartTooltipContent labelFormatter={value => formatLogTime(String(value))} />} />{logLevels.map(level => <Bar key={level} dataKey={level} stackId="events" fill={`var(--color-${level})`} maxBarSize={28} isAnimationActive={false} />)}</BarChart>
</ChartContainer>}
</FramePanel></Frame>
{result.error && <Alert variant="destructive"><AlertTitle>Журнал не загружен</AlertTitle><AlertDescription>{result.error}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert>}
<LogsGrid entries={result.entries} loading={result.loading} total={result.summary?.total ?? null} search={parsed.data.q} onSelect={openEntry} onPause={pause} hasMore={result.hasMore} fetchingMore={result.fetchingMore} loadMore={loadMore} reset={reset} toolbar={<>
<div className="flex flex-wrap items-center gap-2"><form className="flex min-w-0 flex-1 basis-full gap-2 sm:basis-auto sm:max-w-md" onSubmit={event => { event.preventDefault(); navigate({ q: search || null }) }}><Input maxLength={500} aria-label="Поиск по сообщениям" placeholder="Сообщение, IP, интерфейс…" value={search} onChange={event => setSearch(event.target.value)} /><Button size="sm" variant="outline" aria-label="Найти события" type="submit"><SearchIcon /></Button></form><Select value={params.get("preset") || (params.get("levels") === "critical,error" ? "errors" : params.get("levels") === "warning" ? "warnings" : "")} items={PRESETS} onValueChange={value => { navigate(value === "errors" ? { levels: "critical,error", preset: null } : value === "warnings" ? { levels: "warning", preset: null } : { preset: value || null, levels: null }) }}><SelectTrigger size="sm" aria-label="Быстрый срез"><SelectValue placeholder="Все события" /></SelectTrigger><SelectContent>{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent></Select><Button size="sm" variant="ghost" onClick={reset}>Сбросить</Button></div>
<Filters size="sm" fields={fields} query={filterQuery} labels={FILTER_LABELS_RU} onBeforeQueryChange={(next, details) => details.reason !== "negate" && next.rules.every(rule => rule.type === "rule") && new Set(next.rules.map(rule => rule.type === "rule" ? rule.path[0] : "")).size === next.rules.length} onQueryChange={applyFilters} />
<div className="flex flex-wrap items-center gap-2"><Input aria-label="Название представления" placeholder="Название представления" className="w-full sm:w-48" maxLength={60} value={viewName} onChange={event => setViewName(event.target.value)} /><Button size="sm" variant="outline" disabled={!viewName.trim()} onClick={saveView}><BookmarkPlusIcon />Сохранить</Button>{views.map(view => <Button key={view.name} size="sm" variant="ghost" onClick={() => { const saved = new URLSearchParams(view.query); saved.set("server", server); window.history.pushState(null, "", "/logs?" + saved) }}>{view.name}</Button>)}</div>
</>} />
{result.entries.length >= 5000 && <p role="status" className="text-xs text-muted-foreground">Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.</p>}
<p className="text-xs text-muted-foreground">REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.</p>
<LogDetailSheet entry={selected} base={base} onClose={() => setSelected(null)} onSelect={openEntry} />
</div>
}
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { useEffect, useRef } from "react"
import { Badge } from "@/components/ui/badge"
export function NetworkMapLegend() {
const details = useRef<HTMLDetailsElement>(null)
useEffect(() => {
if (window.matchMedia("(max-width: 767px)").matches && details.current) details.current.open = false
}, [])
return (
<details ref={details} open data-map-legend className="absolute left-3 top-3 z-20 max-w-[calc(100%-1.5rem)] rounded-lg border bg-popover text-popover-foreground shadow-sm">
<summary className="cursor-pointer px-3 py-2 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-ring">Легенда карты</summary>
<div role="region" aria-label="Легенда карты" className="flex max-h-[42vh] w-44 max-w-full flex-col gap-2 overflow-y-auto border-t px-3 py-2 text-[11px]">
<div>
<p className="mb-1.5 font-medium text-muted-foreground">Статус устройства</p>
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{[["var(--status-online)", "Онлайн"], ["var(--status-degraded)", "Внимание"], ["var(--status-offline)", "Оффлайн"], ["var(--muted-foreground)", "Неизвестно"]].map(([color, label]) => (
<span key={label} className="flex items-center gap-1.5"><span aria-hidden className="size-2 shrink-0 rounded-full" style={{ background: color }} />{label}</span>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Тип узла</p>
{[["JH", "JumpHost", "var(--chart-1)"], ["EN", "Exit Node", "var(--chart-3)"], ["HR", "Home Router", "var(--chart-2)"]].map(([label, name, color]) => (
<span key={label} className="flex items-center gap-2"><Badge variant="secondary" style={{ color }} className="min-w-8 justify-center text-[10px]">{label}</Badge><span>{name}</span></span>
))}
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded-full border border-current" />WAN · аплинк</span>
<span className="flex items-center gap-2"><span aria-hidden className="mx-1 size-3 rounded border border-current" />Сервис / страна</span>
</div>
<div className="flex flex-col gap-1.5">
<p className="font-medium text-muted-foreground">Связи и измерения</p>
{[
["GRE", "var(--status-online)", undefined],
["GRE + IPsec", "var(--status-online)", "7 4"],
["Поток NetFlow", "var(--chart-4)", "6 5"],
["WAN → JH · модель", "var(--muted-foreground)", "5 4"],
].map(([label, color, dash]) => (
<span key={label} className="flex items-center gap-2"><svg aria-hidden width="28" height="12" className="shrink-0"><line x1="0" y1="6" x2="28" y2="6" stroke={color} strokeWidth="2" strokeDasharray={dash} /></svg>{label}</span>
))}
<p className="text-muted-foreground">RTT · задержка пробы. «—» · нет измерения.</p>
<p className="text-muted-foreground">NetFlow · трафик за 5 минут, не пропускная способность канала.</p>
</div>
</div>
</details>
)
}
+63
View File
@@ -0,0 +1,63 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { can } from "@/lib/auth"
import { Button } from "@/components/ui/button"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { AlertCircleIcon } from "lucide-react"
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
}) {
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
const [receivedAt, setReceivedAt] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [operationError, setOperationError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const operation = useRef<AbortController | null>(null)
const [revision, setRevision] = useState(0)
const accept = useCallback((value: TrafficFlowHostStatus) => {
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
}, [onConfigured])
useEffect(() => () => { operation.current?.abort() }, [])
useEffect(() => {
if (!enabled || !can("mm:traffic:read") || busy) return
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
}
void refresh()
return () => { controller.abort(); if (timer) clearTimeout(timer) }
}, [accept, backendUrl, enabled, busy, revision])
async function apply() {
if (operation.current) return
const controller = new AbortController(); operation.current = controller
setBusy(true); setOperationError(null)
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
}
if (!enabled || !can("mm:traffic:read")) return null
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
<p className="text-sm font-medium">WireGuard на сервере MM</p>
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
{status?.configured && status.reachable ? <>
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
</ul>
</> : null}
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
</div>
</div>
}
+8 -5
View File
@@ -22,6 +22,7 @@ import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { FlowHostPanel } from "./flow-host-panel"
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
@@ -95,7 +96,8 @@ function FlowOverlaySheet({
})
setResult(res)
setTab(res.hostFiles[0]?.id ?? "linux")
toast.success(`wg-flow на ${res.address}`)
if (res.ok) toast.success(`wg-flow на ${res.address}`)
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
onDone?.(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
@@ -125,7 +127,7 @@ function FlowOverlaySheet({
Подключить jump-host
</SheetTitle>
<SheetDescription>
Создаст wg-flow на MikroTik (сервер, listen 13232) и выдаст готовый bash для Linux-хоста Docker MM (клиент).
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
@@ -170,13 +172,14 @@ function FlowOverlaySheet({
</FormField>
{result ? (
<div className="flex min-h-0 flex-col gap-4">
<Alert variant="success">
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
<InfoIcon />
<AlertTitle>Linux-хост /opt/cdn-mm</AlertTitle>
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
<AlertDescription>
Скопируйте вкладку Linux и выполните от root. WG — клиент к JH:13232; контейнер слушает только WG-IP:4739, не 0.0.0.0. Ключ не кладите в git.
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
</AlertDescription>
</Alert>
<FlowHostPanel backendUrl={backendUrl} />
<ul className="flex flex-col gap-1.5">
{result.steps.map((s) => (
<li key={s} className="flex items-start gap-2 text-sm">
+13 -10
View File
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
import { GeoipSettingsSection } from "./geoip-settings-section"
import { FlowHostPanel } from "./flow-host-panel"
import {
generateTrafficFlowKeys,
getTrafficFlowHostFiles,
@@ -24,11 +25,11 @@ import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
const HOST_STEPS = [
"На хосте Docker (не в контейнере mmapp-backend): apt install wireguard (или эквивалент).",
"Скачайте wg-flow.conf и положите в /etc/wireguard/wg-flow.conf.",
"wg-quick up wg-flow (или systemctl enable --now wg-quick@wg-flow).",
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
"В docker-compose у backend раскомментируйте bind IPFIX только на адресе wg-flow.",
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
]
@@ -43,6 +44,7 @@ function NetflowSettingsContent({
enabled: boolean
}) {
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
const [automatic,setAutomatic] = useState(false)
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
const dirty = useRef(new Set<keyof NetflowDraft>())
const [dirtyCount,setDirtyCount] = useState(0)
@@ -147,16 +149,16 @@ function NetflowSettingsContent({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
<Input className="font-mono" aria-label="Collector IP" disabled={disabled} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
</FormField>
<FormField label="Префикс overlay">
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
</FormField>
<FormField label="UDP IPFIX">
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
</FormField>
<FormField label="WG listen">
<Input className="font-mono" aria-label="WG listen" disabled={disabled} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
</FormField>
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
@@ -201,14 +203,15 @@ function NetflowSettingsContent({
{settings?.lastError ? ` · ${settings.lastError}` : ""}
</p>
<div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
{HOST_STEPS.map((s) => (
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
))}
</ol>
</div>
</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
+83
View File
@@ -0,0 +1,83 @@
# Production275 — SYN и задержки API arn-gw01 (2026-10-16)
Поручение: исследовать предупреждения SYN flooding на80/443 и периодически
медленный HTTPS REST на `arn-gw01.rtnt.top`. Выполнены только чтение и короткие
диагностические подключения. Конфигурация production/RouterOS не менялась.
## Результаты
Устройство ID1, DNS IPv4 `62.182.194.146`, REST HTTPS443 с проверкой сертификата.
RouterOS7.24.5 stable,1 CPU. В разовом resource: CPU6%, свободно933036032 байт
из1207959552, uptime5m41s. Перезапуск выполнен до диагностики, не агентом.
1. Первый диагностический GET system/resource завершился тайм-аутом10с.
Следующие чтения: system/clock2307мс, ip/settings638мс, ip/service145мс,
log160мс; отдельный полный resource1170мс. Неверная авторизация не наблюдалась.
2. В первоначальном журнале41 событие: первая отметка02:43:13, оба SYN warning
на80/443 —02:43:20. Совпадение с uptime указывает на события первых секунд
запуска. Позднее часы используют Europe/Stockholm/+02:00, в журнале есть
скачок02:43→04:44; ранние отметки нельзя механически переводить как уже
синхронизированное местное время. Непрерывную SYN-атаку из этих двух записей
вывести нельзя. Последующее кольцо1000 записей уже не содержало предупреждений.
3. Четыре последовательных GET system/identity с новым TCP/TLS, один за раз:
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 1042 | 1141 | 1161 | 1162 |
| 2 | 2 | 3071 | 3189 | 3208 | 3208 |
| 3 | 3 | 3088 | 3233 | 3254 | 3254 |
| 4 | 2 | 5136 | 5254 | 5273 | 5274 |
Значения — накопленные миллисекунды от начала запроса. Все HTTP200;
после TLS до ответа19–21мс. Основная задержка находится до TCP connect.
4. Захват только SYN/SYN-ACK заголовков на eth0 production-хоста,20с,
фильтр строго target IPv4/TCP443. Полезная нагрузка не сохранялась.
Два наших контрольных GET прошли155/149мс; SYN-ACK через18–21мс.
Затем в обычном опросе приложения зафиксированы повторения исходящих SYN:
один исходный порт — первое SYN1792119069.651615, SYN-ACK1792119073.773299
(4.122с, несколько повторов); другой —1792119069.713556→1792119071.757179
(2.044с). При полученном ответе на SYN RTT около18мс. Захват16 пакетов,
dropped by kernel0, завершён автоматически через timeout20s.
5. В02:52:33 UTC (09:52:33 Новосибирск) среди239 TCP conntrack записей4 к
локальным80/443: time-wait1/syn-sent2/syn-recv1. Две от приложения,
две с других внешних адресов — по одной80 и443. Массовый текущий SYN flood
этим одиночным снимком не подтверждён. В этой версии адрес и порт — отдельные
поля; для итогового подсчёта использованы dst-address **и** dst-port.
6. tcp-syncookies=false. www80/www-ssl443/reverse-proxy443 включены,
max-sessions20; FTP/Telnet/SSH уже выключены. В input есть блокировка EVOFW_DENY,
drop UDP53 и accept WireGuard; отдельных ограничений80/443 доверенными
источниками и завершающего drop input в прочитанных правилах нет. RAW пустой.
Правила не менялись; обратный прокси потенциально использует те же порты,
поэтому закрывать их без проверки потребителей нельзя.
7. Scheduler20минут на02:49 UTC: traffic4 error/36 ok, gre_bgp4 error/36 ok.
Выборка последних ошибок называет arn и тайм-аут10с; последние в02:43:49–50 UTC,
рядом с запуском устройства. Успешные задания иногда занимают9–11с суммарно.
Это не доказательство медленного SQL или обработки REST на RouterOS.
## Вывод
Воспроизведены задержки TCP-handshake и повторные SYN **до** TLS/REST.
Разовые низкие CPU и conntrack не показывают постоянного истощения ресурсов.
Причину нужно различать между сетью/фильтрацией по пути и обработкой SYN на CHR;
захват только на отправителе не определяет точное место потери. Историческое
предупреждение при запуске само по себе не доказывает текущую DDoS-атаку.
Приложение сейчас создаёт новые соединения (keepAlive=false, maxSockets2 на origin).
Это увеличивает количество TCP/TLS-handshake, но не доказывает, что приложение
создаёт SYN flood. Менять поведение пула вслепую нельзя: прежние проверки273 касались
также отказов входа. Повышение API timeout лишь скроет часть проявлений.
## Подготовленный следующий шаг
После отдельного разрешения владельца проверить ID1/host и исходный
tcp-syncookies=false, изменить **только** `/ip/settings tcp-syncookies=true`
через REST PATCH. Без перезагрузки, изменения паролей, служб, правил firewall
и маршрутов. Повторно прочитать значение, повторить небольшой последовательный
замер TCP/TLS/HTTP и сверить штатные опросы. Откат — вернуть исходное false после
решения владельца; не объявлять положительный краткий замер доказательством
устранения причины. Это пока предложение, изменение не применялось.
[IP Settings MikroTik](https://help.mikrotik.com/docs/spaces/ROS/pages/103841817/IP%2BSettings)
описывает SYN cookies при переполнении SYN backlog и ограничения TCP extensions.
Это защита от переполнения очереди, она не устраняет потери в сети провайдера.
Точный scope изменения и проверка должны быть согласованы до записи в production.
+80
View File
@@ -0,0 +1,80 @@
# Production276 — включение SYN cookies на arn-gw01 (2026-10-16)
Владелец явно разрешил: «Включить tcp-syncookies и проверить».
Исходная диагностика: [Production275](PRODUCTION-ARN-275.md).
## Применение
Перед записью проверены server ID1/host `arn-gw01.rtnt.top` и исходное
`tcp-syncookies=false`. Первая попытка PATCH `/ip/settings` была отклонена400
`missing or invalid resource identifier`; значение после отказа осталосьfalse.
Этот singleton-раздел изменяется командой POST `/ip/settings/set`.
Выполнена ровно одна успешная команда с единственным параметром
`{"tcp-syncookies":"true"}`. Подтверждение в02:55:01.749 UTC,
повторное чтение02:55:01.893 UTC —true и корректная identity.
Местное время: **2026-10-16 09:55:01 по Новосибирску**.
Пароли, пользователи, firewall, порты, маршруты, приложение, БД и контейнеры
не менялись; перезапуск не выполнялся. Уточнение протокола PATCH→POST не расширяет
согласованный scope и не меняет дополнительные параметры.
## Метод проверки
С тем же production-хостом, адресом устройства, учётной записью и проверкой TLS
повторяются GET system/identity с новым TCP/TLS, последовательно, без повторов
при ошибке. Один запрос каждые4 секунды,8 измерений. Отдельно захват45 секунд
только SYN/SYN-ACK на eth0, фильтр строго target IPv4/TCP443, без сохранения payload.
Проверяются результат чтения настройки, uptime и штатные задания scheduler.
## Контрольные запросы после изменения
С02:55:24 по02:55:53 UTC выполнены8 измерений. Все HTTP200, без повторов.
| Замер | DNS | TCP установлен | TLS завершён | Заголовки ответа | Всего |
| --- | --- | --- | --- | --- | --- |
| 1 | 6 | 25 | 138 | 158 | 159 |
| 2 | 9 | 29 | 137 | 158 | 158 |
| 3 | 3 | 22 | 125 | 144 | 145 |
| 4 | 3 | 22 | 123 | 143 | 143 |
| 5 | 3 | 25 | 130 | 152 | 152 |
| 6 | 2 | 22 | 126 | 168 | 169 |
| 7 | 2 | 21 | 126 | 145 | 145 |
| 8 | 2 | 45 | 313 | 334 | 334 |
Все значения — накопленные миллисекунды от начала запроса. Диапазон полного
запроса143–334мс против1162–5274мс в четырёх исходных измерениях; TCP21–45мс
против1042–5136мс. Настройка повторноtrue, uptime12m42s/CPU14% в замере02:55:54.
В повторном45-секундном захвате42 SYN/SYN-ACK пакета (44 received by filter,
0 dropped by kernel), повторов SYN с тем же исходным портом/sequence в полученном
выводе не обнаружено. Запись payload не выполнялась. Захват завершился по timeout,
фонового процесса захвата после него нет. Это короткая выборка, а не гарантия
отсутствия сетевых потерь при любой нагрузке.
На02:57:30 UTC /09:57:30 Новосибирск, через2мин29с после применения:
| Штатное задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 5 | 0 | 1091мс |
| gre_bgp | 5 | 0 | 426мс |
| uptime_resources | 10 | 0 | 854мс |
| internet_path | 1 | 0 | 284мс |
Считались запуски с started_at после02:55:01.893 UTC. Финальный readbacktrue,
CPU6%, uptime14m19s, RouterOS7.24.5 stable — перезапуска не было. Доступное кольцо
логов04:55:53–04:57:16 по часам устройства не содержит новых SYN warning;
оно не покрывает весь интервал после изменения. Старые события не удалялись.
В коротком сравнении после включения SYN cookies многосекундная задержка TCP
не воспроизвелась, результаты существенно лучше. Это согласуется с гипотезой
проблемы обработки входящих SYN на CHR, но не устанавливает источник всех прежних
потерь и не доказывает результат под другой нагрузкой. Дополнительные изменения
не применялись. Наблюдение и захват завершены; новых фоновых циклов нет.
## Откат
При необходимости по решению владельца повторно проверить устройство и текущее
значение, выполнить POST `/ip/settings/set` с `tcp-syncookies=false`, затем чтение
`/ip/settings?.proplist=tcp-syncookies`. Автоматического отката после успешной
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
+32
View File
@@ -0,0 +1,32 @@
# Production frontend v2.0.1 — 2026-10-15
Владелец отдельно разрешил обновить только frontend и проверить карту. До обновления публичный release-manifest и OCI labels работающего контейнера подтверждали2.0.0/72bf648c. Релиз2.0.1/042407f0 опубликован CI2467 успешно; публикация образа не обновила работающий frontend автоматически. Причина отсутствия узлов в старой версии — отклонение PostgreSQL polledAt (блок265).
На production прочитаны настройки Compose только для сервиса frontend: проект cdn-mm, файл /opt/cdn-mm/docker-compose.yml, образ git.shx.one/denozord/mikrotikmanager-frontend:latest. Учётные данные/полная конфигурация не выводились.
Проверенный образ:
```text
git.shx.one/denozord/mikrotikmanager-frontend@sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647
version=2.0.1
revision=042407f03ed935d26576cbd31dbaaca0e24d8a84
```
После pull по digest и проверки OCI revision выполнен локальный tag этого образа в frontend:latest, затем:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Frontend started2026-10-15T16:22:46.626507761Z (23:22:46 Новосибирск), healthy. Публичный manifest200/version2.0.1. Backend/PostgreSQL сохранили container ID и StartedAt, не перезапускались; Compose-файл, БД, права и маршрутизаторы не менялись. Пересоздание frontend заняло около1,3с по Compose, это не измерение полной пользовательской загрузки.
В браузере после reload подтверждены4 узла (3JH/1EN),4online,25 загруженных GRE; нарисованы3/25 связи. Ошибка первоначальной валидации исчезла, выбор устройства и экспорт доступны. Осталось ограничение read-only: POST network/resolve-hosts запрещён mm:network:write; связи по неразрешённым FQDN не подтверждены. Права не расширялись, все25 туннелей не объявляются подтверждёнными линиями. API до обновления: servers171мс, GRE498мс, speed-probes56мс; исправленная схема принимает4/25/0. Это разовый замер, не гарантия всех задержек.
Старый образ оставлен локально для отката; автоматический prune не запускался. При отдельно согласованном откате:
```bash
docker tag sha256:59afb59c6b7322f339cc1b5cca9a0c9ae725fd04a45a135bf023ed81c6e528a2 git.shx.one/denozord/mikrotikmanager-frontend:latest
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Откат возвращает и дефект карты. Исправление Filters0f0d3aa0 в образ2.0.1 не входит и остаётся локальным. Скриншот с production-данными сохранён приватно в игнорируемом tmp/prod-map269.jpg, в Git не включён.
+36
View File
@@ -0,0 +1,36 @@
# Обновление production до v2.0.2 (2026-10-16)
Владелец разрешил публикацию готовых Filters268, карты270 и recursive271 в main и обновление backend/frontend после успешного CI. Четыре коммита0f0d3aa0..e6a2e400 отправлены без перезаписи истории. Все7 jobs [CI2468](https://git.shx.one/denozord/MikrotikManager/actions/runs/2468) завершились success, опубликован v2.0.2. Версия/тег вычислены CI, package.json вручную не менялся.
Оба образа скачаны по digest, OCI version2.0.2/revision e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8 проверены до перезапуска:
```text
backend: git.shx.one/denozord/mikrotikmanager-backend@sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10
frontend: git.shx.one/denozord/mikrotikmanager-frontend@sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765
```
Проверенные digest локально помечены соответствующими frontend:latest/backend:latest. Compose-файл не редактировался. Выполнено последовательно:
```bash
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Проверка backend healthy; затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
По Новосибирску backend запущен16.10 в00:41:33, frontend в00:42:11. Оба healthy. PostgreSQL сохранил container ID и StartedAt; конфигурация RouterOS и права агентом не менялись. SHA256 Compose до/после одинаков: e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31. Краткое время Compose recreate не используется как измерение пользовательского простоя.
До обновления ошибка JH воспроизведена в браузере. После: HTTP200/live=true/stale=false для3 JH,4/3/5 endpoints; соответствующие UI-срезы загружены без прежней ошибки. Группировка нескольких endpoints одного dst/table в одну строку сохранена. Filters выбранного московского JH:10 правил,0 ложных missing-route warnings. Карта:4 узла/новая легенда/25GRE в данных; после клика computed outline=none, keyboard ring opacity=0. Read-only DNS по-прежнему запрещён mm:network:write, нарисованы3/25 GRE; все25 связей подтверждёнными не объявляются.
[Структурированный протокол](../docs/ux-ui/acceptance/prod272-release.json). Скриншоты production в игнорируемом tmp, в Git не включены. Изменение маршрутов, формы записи и удаление в production не выполнялись.
Старые образы оставлены для отката, prune не запускался. Для восстановления прежних сервисов при необходимости:
```bash
docker tag sha256:6f344eecb5dd0ec820c23b8d71f89a16ebccff3ac898d954eadd159f2611b6a4 git.shx.one/denozord/mikrotikmanager-backend:latest
docker tag sha256:73ac48979084c0c93b77e75308a9e2f002481586704d0c03fd9a7b5e39511647 git.shx.one/denozord/mikrotikmanager-frontend:latest
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never backend
# Дождаться healthy, затем:
docker compose -p cdn-mm -f /opt/cdn-mm/docker-compose.yml up -d --no-deps --pull never frontend
```
Откат вернёт исправленные дефекты. Он не понадобился и не выполнялся. Настоящий протокол сохранён отдельным локальным коммитом без повторного push, чтобы документирование приёмки само не запускало следующий релиз.
+50
View File
@@ -0,0 +1,50 @@
# Индекс истории планировщика — 2026-10-15
Диагностика production: scheduler_runs около1.06M строк/853МБ, retention DELETE после каждого задания, индекса finished_at нет. Наблюдалось ожидание IO/AioIoCompletion; EXPLAIN эквивалентного SELECT показал полный скан. Это подтверждает лишнюю нагрузку БД, но не доказывает причину каждого тайм-аута RouterOS.
Миграция0013 создаёт индекс для новых установок и последующего старта backend. Для работающей установки ниже приведён отдельный online-вариант. **На production выполнен 2026-10-15, запуск в14:55:45 UTC, после разрешения владельца только на индекс и проверку нагрузки.** Создание индекса изменяет БД и временно добавляет нагрузку на CPU/диск.
Подключение psql к текущему mmapp-postgres, без вывода пароля:
```bash
docker exec -it mmapp-postgres sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
```
Сначала проверить, что имя не занято другим или невалидным индексом:
```sql
SELECT indexrelid::regclass, indisvalid, indisready, pg_get_indexdef(indexrelid)
FROM pg_index WHERE indexrelid = to_regclass('public.idx_scheduler_runs_finished_at');
```
Если индекс отсутствует, выполнить **вне BEGIN/транзакции**:
```sql
SET lock_timeout = '5s';
SET statement_timeout = '10min';
CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at
ON public.scheduler_runs (finished_at);
RESET statement_timeout;
RESET lock_timeout;
```
При прерывании concurrent build не повторять вслепую с IF NOT EXISTS: сначала проверить indisvalid/indisready. Если остался невалидный индекс, отдельно согласовать его удаление и повторное построение.
После успеха повторить проверку определения и валидности индекса, снять EXPLAIN условия retention и сравнить CPU/IO в нескольких окнах. При очень большой доле устаревших строк PostgreSQL может обоснованно выбрать Seq Scan; не отключать его принудительно. Срок хранения30 дней и код удаления не меняются. Индекс не удаляет записи и не требует перезапуска контейнеров.
Удаление индекса, перезапуски и изменение ресурсов не входят в эту процедуру. Исправление карты — отдельный frontend-патч и само этим SQL не разворачивается.
## Результат применения (2026-10-15)
CREATE INDEX CONCURRENTLY завершён успешно; indisvalid/indisready=true. Повторная проверка в15:01:30 UTC:23МБ,697 использований индекса. EXPLAIN эквивалентного SELECT retention: Bitmap Heap Scan → Bitmap Index Scan idx_scheduler_runs_finished_at вместо Parallel Seq Scan. EXPLAIN ANALYZE DELETE не запускался.
| Измерение | До индекса | После,14:56 UTC | После,15:00 UTC |
| --- | --- | --- | --- |
| CPU контейнера PostgreSQL, docker stats | 66,51% | 0,16% | 0,13% |
| CPU idle сервера, секундные vmstat | 0% | 74–94% | 94–97% |
| CPU iowait сервера, секундные vmstat | 29–34% | 0–5% | 0–1% |
| IO pressure some avg10 | 63,67% | 1,70% | 0,48% |
Первая строка vmstat (среднее с загрузки ОС) исключена из сравнения. Замеры показывают устойчивое улучшение в пределах нескольких минут обычной работы, а не нагрузочную приёмку или гарантию отсутствия всех тайм-аутов. PostgreSQL/контейнеры не перезапускались; ручное удаление данных, изменение retention и параметров приложения не выполнялись. Действующий штатный retention продолжал работать.
Автоматическая проверка фактического SQL миграции выполнена на отдельном тестовом Debian в контейнере mmapp-test-backend:20тыс. временных строк, повторный запуск, валидность индекса, смена плана и сохранение результата выборки. Тест не запускает bootstrap и не изменяет public-таблицы. Backend build и scoped ESLint прошли. [Структурированный протокол](../docs/ux-ui/acceptance/prod266-retention.json).
+72
View File
@@ -0,0 +1,72 @@
# Production273 — отказы входа RouterOS (2026-10-16)
Диагностика production выполнена только чтением. Пароли, пользователи, службы,
firewall, задания RouterOS и контейнеры не изменялись. Секреты не включены.
## Наблюдения
- `nov-gw01.rtnt.top`, server ID3: RouterOS **7.24.4 stable**.
- Рядом зафиксированы `500 / too many concurrent logins (6)` и `401 Unauthorized`
на `/interface`, `/interface/gre`, `/routing/bgp/session`. Последние отказы серии:
**2026-10-16 01:11:40–44 по Новосибирску** (2026-10-15 18:11:40–44 UTC).
- В последних15 минутах на01:27: traffic30/30 ok, gre_bgp30/30 ok,
internet_path3/3 ok, ошибок этих заданий нет.
- Тот же сохранённый пароль успешно использован для system/resource, user/active,
ip/service, interface/gre (3 записи), routing/bgp/session (3 записи).
Пользователь приложения: full, disabled=false, address пустой, api/rest-api
разрешены. Это подтверждает текущий доступ, но не объясняет каждый старый401.
- FTP/Telnet включены. В кольце журнала01:26:00–01:26:47:10 отказов входа
с посторонних адресов —9 FTP и1 Telnet; отказов с адреса приложения в этой
выборке нет. Следующий снимок показывает **7 внешних Telnet-соединений**.
Подбор учётных данных наблюдается; успешный посторонний вход не установлен.
- У www/www-ssl/api/api-ssl max-sessions=20. Число6 в ошибке нельзя автоматически
отождествлять с этим параметром или количеством TCP-соединений приложения.
- user/active содержит много старых rest-api записей с адреса приложения;
api запись в момент чтения одна. По одному списку нельзя доказывать утечку
живых TCP-соединений или принудительно удалять записи.
- RouterOS scheduler `evofw-sync`: enabled, interval1m, run-count625.
В доступном кольце01:26–01:30:866 изменений от scheduler, из них860 добавлений
address-list и6 изменений filter rule. Два script/job в снимке, включая начатый
01:22:08. Скрипты не выгружались и не менялись; связь с отказами не доказана.
- Общий HTTP/HTTPS Agent backend ограничивает сокеты до2 **на origin**.
Одно задание планировщика защищено от повторного запуска; разные задания
работают одновременно. Шесть одновременных REST-запросов из основного процесса
приложения не установлены.
## Вывод и границы
Неверный пароль как причина текущего доступа не подтвердился. RouterOS явно
сообщил о насыщении механизма входа;401 возникали рядом, затем без изменения
пароля прекратились. Связь с внешним подбором, продлением REST-сессии или локальным
scheduler необходимо различить дальнейшим наблюдением.15 минут успеха не доказывают
устранение причины.
[Официальная документация REST](https://manual.mikrotik.com/docs/developer-guides/rest-api/)
описывает внутренний API login, повторное использование логина10 минут и политики
api/rest-api. Страница относится к7.26; поведение7.24.4 не считать идентичным
без отдельного подтверждения.
## Локальная правка
- 500 с точным JSON detail `too many concurrent logins (...)` получает подсказку
«RouterOS занят: превышен лимит одновременных входов».
- 401 больше не обозначается безусловно как неверный пароль: предлагается проверить
учётные данные, права API/REST и ограничения входа.
- Исходные HTTP status/path/body сохранены; повторов чтения/записи не добавлено.
Другие500 не классифицируются как лимит входов.
- 12 HTTP-проверок и профиль ros-file, backend production build, scoped ESLint,
diff check прошли. Локально, без push/deploy.
## Следующий шаг, требующий разрешения
Временно отключить **только** ftp (`*1`,21) и telnet (`*0`,23) на ID3.
Перед записью повторно проверить имя/ID и исходный disabled каждой службы.
Это отключит FTP/Telnet подключения; Winbox, HTTPS, API и маршрутизация сохраняются.
Для этой диагностики production доступ предоставлен на чтение: запись требует
отдельного разрешения владельца. Если эти службы нужны, согласовать доверенные адреса.
После разрешения: сохранить исходные disabled, отключить две службы, проверить
применение и REST-доступ; наблюдать обычный опрос не менее двух периодов продления
REST-логина, сравнить ошибки и login failures. Не повышать max-sessions, не менять
пароль, не удалять user/active, не перезапускать маршрутизатор. Результат записать
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
+78
View File
@@ -0,0 +1,78 @@
# Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
Владелец явно разрешил отключить FTP и Telnet на `nov-gw01.rtnt.top`.
Предыдущая диагностика: [Production273](PRODUCTION-ROUTEROS-273.md).
## Изменение
Перед записью проверены server ID3/host, единственность статических служб,
их ID, порты21/23 и исходный disabled=false. Поочерёдно применено только
`disabled=true`:
| Служба | ID | До | После | Время UTC 2026-10-15 |
| --- | --- | --- | --- | --- |
| ftp | *1 | false | true | 18:33:45.013 |
| telnet | *0 | false | true | 18:33:45.322 |
Повторное чтение18:33:45.582 UTC подтвердило обе службы выключенными.
Это **2026-10-16 01:33:45 по Новосибирску**.
www80/www-ssl443/winbox8291/api8728/api-ssl8729 остались disabled=false,
как до операции. REST identity подтвердил имя устройства; resource —7.24.4 stable,
uptime10h28m24s, без перезапуска.
Пароль, пользователи, firewall, маршруты, локальные задания RouterOS, контейнеры
и приложение не изменялись. Локальная правка подсказок273 не развёртывалась.
## Метод наблюдения
Штатные задания продолжают работать с прежними интервалами. Диагностический
процесс раз в минуту только читает scheduler_runs и RouterOS log; дополнительных
запусков заданий/нагрузочных тестов нет. Подсчитываются ok/error после18:33:45.582 UTC
для traffic, gre_bgp, internet_path, uptime_resources. Журнал логинов проверяется
с продвижением по ID и контролем перекрытия кольцевого буфера, без вывода секретов.
## Итог наблюдения
Окончание:2026-10-15 18:54:44 UTC /2026-10-16 01:54:44 по Новосибирску,
почти21 минута после применения. После изменения в scheduler_runs:
| Задание | Успешно | Ошибки | Максимальная длительность |
| --- | --- | --- | --- |
| traffic | 42 | 0 | 2278мс |
| gre_bgp | 42 | 0 | 910мс |
| internet_path | 4 | 0 | 809мс |
| uptime_resources | 84 | 0 | 1937мс |
Итоговое чтение: статические ftp/telnet disabled=true; www/www-ssl/winbox/api/api-ssl
остались включены. Identity/REST работает, RouterOS7.24.4 stable,
uptime10h49m22s — перезапуска не было. В списке остаются6 динамических строк Telnet;
отключение службы не доказывает завершение всех прежних соединений. Другие службы,
соединения и firewall принудительно не менялись.
В18:55:47 UTC проверено новое TCP-подключение с production backend к имени
устройства:21 и23 вернули ECONNREFUSED. Новые подключения к этим портам отклоняются.
В наблюдавшихся новых логах нет отказов FTP/Telnet, но есть2 отказа Winbox.
Их источники не были сохранены: не объявлять эти2 события доказанным внешним
подбором. Кольцо RouterOS переполнялось scheduler-изменениями; обнаружено4 пропуска
перекрытия, поэтому отсутствие FTP/Telnet failures не доказано для всего интервала.
Статистика scheduler_runs этим ограничением не затронута.
Изменение служб само по себе не доказывает причину предыдущих401/500: они
прекратились ещё до операции. Зафиксировано отсутствие повторения после отключения,
а не гарантированное устранение первопричины. Автоматический сбор наблюдений завершён.
## Восстановление исходного состояния
Владелец отдельно подтвердил: **«Оставить FTP и Telnet выключенными»**.
После наблюдения службы не включать обратно. При необходимости отдельного отката:
повторно проверить host, имена/ID, вернуть disabled=false только ftp/telnet и
сверить чтением. Других изменений конфигурации этим блоком не выполнять.
## Дополнительный вопрос о PostgreSQL
По вопросу владельца выполнена только читающая проверка production PostgreSQL:
pg_is_in_recovery=false, wal_level=replica, pg_stat_replication count=0,
pg_replication_slots count=0, pg_subscription count=0, synchronous_standby_names
пустой. В конфигурации проекта один PostgreSQL и один DATABASE_URL backend.
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
+27
View File
@@ -0,0 +1,27 @@
# Optional addition to docker-compose.cdn-mm.yml. Run the exported installer first.
# Keep this file in every subsequent docker compose -f invocation.
services:
netflow-agent:
image: git.shx.one/denozord/mikrotikmanager-netflow-agent:${MM_NETFLOW_AGENT_IMAGE_TAG:-latest}
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
depends_on:
netflow-agent:
condition: service_healthy
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.22 AS base
RUN apk add --no-cache python3 wireguard-tools iproute2
COPY agent.py /app/agent.py
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
HEALTHCHECK --interval=15s --timeout=5s --retries=3 CMD ["python3", "/app/agent.py", "--healthcheck"]
ENTRYPOINT ["python3", "/app/agent.py"]
FROM base AS test
COPY test_agent.py /app/test_agent.py
FROM base AS runtime
+367
View File
@@ -0,0 +1,367 @@
"""Local, single-interface WireGuard controller. Never executes wg-quick/hooks."""
import base64
import binascii
import http.client
import http.server
import ipaddress
import json
import os
from pathlib import Path
import re
import socket
import socketserver
import subprocess
import sys
import tempfile
import threading
IFACE = "wg-flow"
CONFIG = Path(os.environ.get("FLOW_AGENT_CONFIG_DIR", "/etc/wireguard"))
SOCKET = os.environ.get("FLOW_AGENT_SOCKET", "/run/mm-netflow-host/agent.sock")
LOCK = threading.RLock()
MAX_BODY = 262144
class AgentError(Exception):
pass
def run(*args, input=None):
try:
result = subprocess.run(args, input=input, text=True, capture_output=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
raise AgentError("Команда WireGuard/IP недоступна или превысила время ожидания") from None
if result.returncode:
# stderr/stdout can contain keys and must never reach an HTTP response/log.
raise AgentError("Операция WireGuard/IP не выполнена")
return result.stdout.strip()
def key(value):
try:
if not isinstance(value, str) or len(value) != 44 or len(base64.b64decode(value, validate=True)) != 32:
raise ValueError()
except (ValueError, binascii.Error):
raise AgentError("Некорректный ключ WireGuard") from None
return value
def port(value):
if type(value) is not int or not 1 <= value <= 65535:
raise AgentError("Некорректный UDP-порт")
return value
def endpoint(value):
if not isinstance(value, str) or not re.fullmatch(r"(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}", value):
raise AgentError("Некорректный endpoint")
port(int(value.rsplit(":", 1)[1]))
return value
def parse_conf(text):
interface, peers, section = {}, [], None
allowed = {"interface": {"PrivateKey", "Address", "ListenPort", "MTU"},
"peer": {"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}}
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line == "[Interface]" and section is None:
section, current = "interface", interface
elif line == "[Peer]" and section is not None:
section, current = "peer", {}
peers.append(current)
else:
name, sep, value = line.partition("=")
name, value = name.strip(), value.strip()
if not sep or section is None or name not in allowed[section] or name in current or not value:
raise AgentError("Конфигурация wg-flow содержит неподдерживаемые или повторные параметры; требуется ручная проверка")
current[name] = value
if not interface or not {"PrivateKey", "Address", "ListenPort"} <= interface.keys():
raise AgentError("Неполная конфигурация wg-flow")
key(interface["PrivateKey"])
try:
ipaddress.IPv4Interface(interface["Address"])
port(int(interface["ListenPort"]))
if "MTU" in interface and not 576 <= int(interface["MTU"]) <= 9000:
raise ValueError()
for peer in peers:
key(peer.get("PublicKey"))
if "PresharedKey" in peer:
key(peer["PresharedKey"])
if "Endpoint" in peer:
endpoint(peer["Endpoint"])
if peer.get("AllowedIPs"):
networks(peer["AllowedIPs"].split(","))
if "PersistentKeepalive" in peer and not 0 <= int(peer["PersistentKeepalive"]) <= 65535:
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Некорректные параметры wg-flow") from None
if len({p["PublicKey"] for p in peers}) != len(peers):
raise AgentError("Повторный публичный ключ пира")
return interface, peers
def networks(values):
if not isinstance(values, list) or not values:
raise AgentError("AllowedIPs должны содержать адреса")
try:
return [ipaddress.ip_network(v.strip(), strict=False) for v in values]
except (ValueError, AttributeError):
raise AgentError("Некорректные AllowedIPs") from None
def validate(raw):
fields = {"collectorIp", "prefix", "flowListenPort", "wgListenPort", "hostPublicKey", "hostPrivateKey", "peers"}
if not isinstance(raw, dict) or set(raw) != fields:
raise AgentError("Некорректный снимок NetFlow")
try:
collector = ipaddress.IPv4Address(raw["collectorIp"])
prefix = ipaddress.IPv4Network(raw["prefix"], strict=False)
if prefix.prefixlen != 24 or collector not in prefix or collector in (prefix.network_address, prefix.broadcast_address):
raise ValueError()
except (ValueError, TypeError):
raise AgentError("Требуется IPv4 overlay /24 и адрес коллектора внутри него") from None
port(raw["flowListenPort"])
port(raw["wgListenPort"])
key(raw["hostPublicKey"])
key(raw["hostPrivateKey"])
if run("wg", "pubkey", input=raw["hostPrivateKey"] + "\n") != raw["hostPublicKey"]:
raise AgentError("Публичный и приватный ключи MM не соответствуют друг другу")
binding = json.loads((CONFIG / "binding.json").read_text())
for name in ("collectorIp", "prefix", "flowListenPort", "wgListenPort"):
if binding.get(name) != raw[name]:
raise AgentError("Адрес, префикс или порт отличаются от установки агента; повторите установщик")
if not isinstance(raw["peers"], list) or len(raw["peers"]) > 253:
raise AgentError("Некорректный список пиров")
peers, ids, keys = [], set(), set()
for peer in raw["peers"]:
if not isinstance(peer, dict) or not {"serverId", "publicKey", "allowedIps"} <= peer.keys() or set(peer) - {"serverId", "publicKey", "allowedIps", "endpoint"}:
raise AgentError("Некорректный пир")
peer_key = key(peer["publicKey"])
if type(peer["serverId"]) is not int or peer["serverId"] <= 0 or peer["serverId"] in ids or peer_key in keys or peer_key == raw["hostPublicKey"]:
raise AgentError("Повторный или некорректный пир")
ids.add(peer["serverId"])
keys.add(peer_key)
nets = networks(peer["allowedIps"])
if len(nets) != 1 or nets[0].version != 4 or nets[0].prefixlen != 32 or nets[0].network_address not in prefix or nets[0].network_address in (collector, prefix.network_address, prefix.broadcast_address):
raise AgentError("Пиру разрешён только собственный IPv4 /32 внутри overlay")
data = {"PublicKey": peer_key, "AllowedIPs": str(nets[0])}
if peer.get("endpoint"):
data.update(Endpoint=endpoint(peer["endpoint"]), PersistentKeepalive="25")
peers.append(data)
return peers
def merge_peers(existing, desired):
merged = {peer["PublicKey"]: dict(peer) for peer in existing}
for peer in desired:
public = peer["PublicKey"]
# Replacing a PSK would silently break a previously working tunnel.
if merged.get(public, {}).get("PresharedKey", "A" * 43 + "=") != "A" * 43 + "=":
raise AgentError("Управляемый пир использует preshared key; требуется ручная проверка")
merged[public] = dict(peer)
rows = list(merged.values())
wanted = {p["PublicKey"] for p in desired}
for i, left in enumerate(rows):
for right in rows[i + 1:]:
if left["PublicKey"] not in wanted and right["PublicKey"] not in wanted:
continue
if not left.get("AllowedIPs") or not right.get("AllowedIPs"):
continue
if any(a.version == b.version and a.overlaps(b) for a in networks(left["AllowedIPs"].split(",")) for b in networks(right["AllowedIPs"].split(","))):
raise AgentError("AllowedIPs управляемого пира пересекаются с другим пиром")
return rows
def render(interface, peers, native=False):
rows = ["[Interface]"]
rows += [f"{k} = {v}" for k, v in interface.items() if not native or k not in ("Address", "MTU")]
for peer in peers:
rows += ["", "[Peer]"] + [f"{k} = {v}" for k, v in peer.items() if k != "AllowedIPs" or v]
return "\n".join(rows) + "\n"
def atomic_write(path, text):
fd, name = tempfile.mkstemp(prefix=".flow-", dir=path.parent)
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
if os.name == "posix":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
def sync(text):
# wg requires a file. mkstemp creates it with mode 0600.
fd, name = tempfile.mkstemp(prefix="flow-sync-")
try:
with os.fdopen(fd, "w") as stream:
stream.write(text)
run("wg", "syncconf", IFACE, name)
finally:
os.unlink(name)
def live():
dump = run("wg", "show", IFACE, "dump").splitlines()
if not dump or len(dump[0].split("\t")) != 4:
raise AgentError("Интерфейс wg-flow недоступен")
interface = dump[0].split("\t")
peers, raw_peers = [], []
for line in dump[1:]:
cols = line.split("\t")
if len(cols) != 8:
raise AgentError("Некорректный ответ WireGuard")
public, psk, ep, ips, handshake, rx, tx, keepalive = cols
peers.append({"publicKey": public, "allowedIps": ips.split(",") if ips != "(none)" else [],
"lastHandshakeAt": int(handshake) or None, "receivedBytes": int(rx), "sentBytes": int(tx)})
item = {"PublicKey": public, "AllowedIPs": ips if ips != "(none)" else ""}
if psk != "(none)":
item["PresharedKey"] = psk
if ep != "(none)":
item["Endpoint"] = ep
item["PersistentKeepalive"] = keepalive
raw_peers.append(item)
addresses = json.loads(run("ip", "-j", "-4", "address", "show", "dev", IFACE))
cidrs = [f"{a['local']}/{a['prefixlen']}" for row in addresses for a in row.get("addr_info", []) if a.get("family") == "inet"]
return {"interfaceUp": any("UP" in row.get("flags", []) for row in addresses), "publicKey": interface[1], "listenPort": int(interface[2]), "addresses": cidrs, "peers": peers}, raw_peers
def check(raw, apply=False):
desired = validate(raw)
path = CONFIG / "wg-flow.conf"
previous = path.read_text()
interface, stored = parse_conf(previous)
expected_address = f"{raw['collectorIp']}/{raw['prefix'].split('/')[1]}"
if run("wg", "pubkey", input=interface["PrivateKey"] + "\n") != raw["hostPublicKey"] or interface["Address"] != expected_address or int(interface["ListenPort"]) != raw["wgListenPort"]:
raise AgentError("Ключ, адрес или порт существующего wg-flow отличаются от MM; требуется ручная проверка")
status, runtime = live()
if not status["interfaceUp"]:
raise AgentError("Интерфейс wg-flow выключен; проверьте wg-quick на хосте")
if status["publicKey"] != raw["hostPublicKey"] or expected_address not in status["addresses"] or status["listenPort"] != raw["wgListenPort"]:
raise AgentError("Работающий wg-flow отличается от конфигурации; требуется ручная проверка")
# Preserve both persistent and runtime-only third-party peers. Detect conflicts before writing.
combined = {p["PublicKey"]: p for p in runtime}
combined.update({p["PublicKey"]: p for p in stored})
merged = merge_peers(list(combined.values()), desired)
runtime_merged = merge_peers(runtime, desired)
# Do not reset a third-party peer's runtime settings to an older persistent file.
# Preserve roaming endpoints of managed peers unless the persisted endpoint was edited.
stored_by_key = {p["PublicKey"]: p for p in stored}
runtime_by_key = {p["PublicKey"]: p for p in runtime}
desired_by_key = {p["PublicKey"]: p for p in desired}
for peer in runtime_merged:
public = peer["PublicKey"]
if public in desired_by_key and stored_by_key.get(public, {}).get("Endpoint") == desired_by_key[public].get("Endpoint"):
if runtime_by_key.get(public, {}).get("Endpoint"):
peer["Endpoint"] = runtime_by_key[public]["Endpoint"]
def matches(actual, persistent=False):
by_key = {p["PublicKey"]: p for p in actual}
return all(p["PublicKey"] in by_key and all(by_key[p["PublicKey"]].get(k) == v for k, v in p.items() if persistent or k != "Endpoint") for p in desired)
matched = matches(stored, persistent=True) and matches(runtime)
if apply:
rollback = run("wg", "showconf", IFACE)
try:
atomic_write(path, render(interface, merged))
# Keep the running interface header verbatim (including a possible fwmark).
header = rollback.split("[Peer]", 1)[0].rstrip()
peer_config = render({}, runtime_merged, native=True).removeprefix("[Interface]\n")
sync(header + "\n" + peer_config)
status, after = live()
if not matches(after):
raise AgentError("Применение пиров не подтверждено")
except Exception:
failures = []
for action in (lambda: atomic_write(path, previous), lambda: sync(rollback)):
try:
action()
except Exception:
failures.append(True)
raise AgentError("Применение не удалось; " + ("восстановление требует ручной проверки" if failures else "предыдущая конфигурация восстановлена")) from None
matched = True
return {**status, "configMatches": matched}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def handle_request(self):
try:
with LOCK:
if self.command == "GET" and self.path == "/status":
result, _ = live()
result["configMatches"] = None
elif self.command == "POST" and self.path in ("/check", "/apply"):
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= MAX_BODY or self.headers.get("Transfer-Encoding"):
raise AgentError("Некорректный размер запроса")
raw = json.loads(self.rfile.read(size))
result = check(raw, apply=self.path == "/apply")
else:
self.respond(404, {"error": "Операция не поддерживается"})
return
self.respond(200, result)
except AgentError as error:
self.respond(409, {"error": str(error)})
except Exception:
self.respond(503, {"error": "Агент NetFlow не готов; проверьте конфигурацию и интерфейс хоста"})
def setup(self):
super().setup()
self.connection.settimeout(15)
def respond(self, code, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
do_GET = handle_request
do_POST = handle_request
class Server(socketserver.ThreadingMixIn, getattr(socketserver, "UnixStreamServer", socketserver.TCPServer)):
daemon_threads = True
class UnixConnection(http.client.HTTPConnection):
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(3)
self.sock.connect(SOCKET)
def main():
if not hasattr(socketserver, "UnixStreamServer"):
raise SystemExit("NetFlow agent requires Linux Unix sockets")
if "--healthcheck" in sys.argv:
client = UnixConnection("localhost")
client.request("GET", "/status")
response = client.getresponse()
sys.exit(0 if response.status == 200 and json.loads(response.read()).get("interfaceUp") else 1)
os.umask(0o077)
Path(SOCKET).parent.mkdir(parents=True, exist_ok=True)
if os.path.lexists(SOCKET):
Path(SOCKET).unlink()
with Server(SOCKET, Handler) as server:
os.chmod(SOCKET, 0o660)
print("NetFlow agent: Unix API ready", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Usage: sudo bash install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739
set -euo pipefail
umask 077
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Запустите от root" >&2; exit 1; }
SOURCE=$(realpath "${1:?Укажите скачанный wg-flow.conf}")
COMPOSE_DIR=$(realpath "${2:-/opt/cdn-mm}")
FLOW_PORT=${3:-4739}
[[ "$FLOW_PORT" =~ ^[0-9]+$ ]] && (( FLOW_PORT >= 1 && FLOW_PORT <= 65535 )) || exit 1
command -v docker >/dev/null
docker compose version >/dev/null
systemctl show docker.service --property=LoadState --value | grep -qx loaded
if ! command -v wg >/dev/null || ! command -v python3 >/dev/null || ! command -v modprobe >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y wireguard-tools python3 iproute2 kmod
fi
modprobe wireguard
[[ -f "$COMPOSE_DIR/docker-compose.yml" ]] || { echo "Не найден docker-compose.yml" >&2; exit 1; }
AGENT_IMAGE=${MM_NETFLOW_AGENT_IMAGE:-git.shx.one/denozord/mikrotikmanager-netflow-agent:latest}
[[ "$AGENT_IMAGE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@-]+$ ]] || { echo "Некорректное имя образа" >&2; exit 1; }
# The image must be available before changing the host configuration.
docker pull "$AGENT_IMAGE"
TASK_TEMP=$(mktemp -d)
trap 'rm -rf -- "$TASK_TEMP"' EXIT
python3 - "$SOURCE" "$COMPOSE_DIR" "$FLOW_PORT" "$TASK_TEMP" <<'PY'
import base64, ipaddress, json, os, pathlib, re, subprocess, sys
source, directory, port, temp = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), int(sys.argv[3]), pathlib.Path(sys.argv[4])
def read(text):
section, fields, peers = None, {}, []
allowed = {'Interface': {'PrivateKey','Address','ListenPort','MTU'}, 'Peer': {'PublicKey','AllowedIPs','Endpoint','PersistentKeepalive','PresharedKey'}}
for raw in text.splitlines():
line = raw.split('#',1)[0].strip()
if not line: continue
if line == '[Interface]' and section is None: section, current = 'Interface', fields
elif line == '[Peer]' and section is not None:
section, current = 'Peer', {}; peers.append(current)
else:
key, sep, value = line.partition('='); key, value = key.strip(), value.strip()
if not sep or section not in allowed or key not in allowed[section] or key in current or not value:
raise SystemExit('Конфигурация содержит неподдерживаемые параметры; требуется ручная проверка')
current[key] = value
try:
addr = ipaddress.IPv4Interface(fields['Address'])
if addr.network.prefixlen != 24 or addr.ip in (addr.network.network_address, addr.network.broadcast_address): raise ValueError()
if len(base64.b64decode(fields['PrivateKey'], validate=True)) != 32 or not 1 <= int(fields['ListenPort']) <= 65535: raise ValueError()
if 'MTU' in fields and not 576 <= int(fields['MTU']) <= 9000: raise ValueError()
for peer in peers:
if len(base64.b64decode(peer['PublicKey'], validate=True)) != 32: raise ValueError()
for network in peer['AllowedIPs'].split(','): ipaddress.ip_network(network.strip(), strict=False)
if 'Endpoint' in peer and not re.fullmatch(r'(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]{1,5}', peer['Endpoint']): raise ValueError()
if 'PersistentKeepalive' in peer and not 0 <= int(peer['PersistentKeepalive']) <= 65535: raise ValueError()
except (ValueError, KeyError): raise SystemExit('Некорректная конфигурация WireGuard; ключи не изменены')
return fields, addr
expected, addr = read(source.read_text())
active = pathlib.Path('/etc/wireguard/wg-flow.conf')
candidate = active.read_text() if active.exists() else source.read_text()
actual, actual_addr = read(candidate)
for key in ('PrivateKey','Address','ListenPort'):
if actual[key] != expected[key]: raise SystemExit('Ключ, адрес или порт существующего туннеля отличаются от MM; установка остановлена')
public = subprocess.run(['wg','pubkey'], input=actual['PrivateKey']+'\n',text=True,capture_output=True,check=True).stdout.strip()
if subprocess.run(['ip','link','show','dev','wg-flow'],capture_output=True).returncode == 0:
dump = subprocess.run(['wg','show','wg-flow','dump'],capture_output=True,text=True,check=True).stdout.splitlines()
head = dump[0].split('\t')
addresses = json.loads(subprocess.run(['ip','-j','-4','address','show','dev','wg-flow'],capture_output=True,text=True,check=True).stdout)
if head[1] != public or int(head[2]) != int(actual['ListenPort']) or not any(a.get('local') == str(addr.ip) and a.get('prefixlen') == addr.network.prefixlen for row in addresses for a in row.get('addr_info',[])):
raise SystemExit('Работающий wg-flow отличается от MM; установка остановлена')
binding = dict(collectorIp=str(addr.ip), prefix=str(addr.network), flowListenPort=port, wgListenPort=int(actual['ListenPort']))
(temp/'binding.json').write_text(json.dumps(binding))
(temp/'wg-flow.conf').write_text(candidate)
(temp/'collector').write_text(str(addr.ip))
PY
COLLECTOR_IP=$(cat "$TASK_TEMP/collector")
# Keep a dedicated override; never replace docker-compose.override.yml.
cat > "$TASK_TEMP/compose.yml" <<EOF
services:
netflow-agent:
image: "$AGENT_IMAGE"
container_name: mmapp-netflow-agent
restart: unless-stopped
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=8m"]
volumes:
- ./data/netflow-wireguard:/etc/wireguard
- ./data/netflow-agent-socket:/run/mm-netflow-host
logging:
driver: json-file
options: {max-size: "5m", max-file: "2"}
backend:
environment:
FLOW_AGENT_SOCKET: /run/mm-netflow-host/agent.sock
FLOW_LISTEN_HOST: "0.0.0.0"
volumes:
- ./data/netflow-agent-socket:/run/mm-netflow-host:ro
ports:
- "$COLLECTOR_IP:$FLOW_PORT:$FLOW_PORT/udp"
depends_on:
netflow-agent:
condition: service_healthy
EOF
COMPOSE_ARGS=(-f "$COMPOSE_DIR/docker-compose.yml")
if [[ -f "$COMPOSE_DIR/docker-compose.override.yml" ]]; then COMPOSE_ARGS+=(-f "$COMPOSE_DIR/docker-compose.override.yml"); fi
# Render first, and reject an existing public/incorrect IPFIX binding before touching WireGuard.
docker compose --project-directory "$COMPOSE_DIR" "${COMPOSE_ARGS[@]}" -f "$TASK_TEMP/compose.yml" config --format json > "$TASK_TEMP/merged.json"
python3 - "$TASK_TEMP/merged.json" "$COLLECTOR_IP" "$FLOW_PORT" <<'PY'
import json,sys
config=json.load(open(sys.argv[1])); ip=sys.argv[2]; port=int(sys.argv[3])
for bind in config['services']['backend'].get('ports',[]):
if bind.get('protocol','tcp') == 'udp' and (bind['target'] in (4739,port) or str(bind.get('published')) == str(port)):
if bind.get('host_ip') != ip or bind['target'] != port or str(bind.get('published')) != str(port):
raise SystemExit('В Compose уже есть другой IPFIX bind. Согласуйте его с адресом и портом MM и повторите установку.')
PY
if docker container inspect mmapp-netflow-agent >/dev/null 2>&1; then docker stop mmapp-netflow-agent; fi
install -d -m 700 /etc/wireguard "$COMPOSE_DIR/data/netflow-wireguard" "$COMPOSE_DIR/data/netflow-agent-socket"
if [[ -e /etc/wireguard/wg-flow.conf ]]; then
cp -L /etc/wireguard/wg-flow.conf "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf.backup.$(date +%s)"
fi
install -m 600 "$TASK_TEMP/wg-flow.conf" "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf"
install -m 600 "$TASK_TEMP/binding.json" "$COMPOSE_DIR/data/netflow-wireguard/binding.json"
ln -sfn "$COMPOSE_DIR/data/netflow-wireguard/wg-flow.conf" /etc/wireguard/wg-flow.conf
install -m 600 "$TASK_TEMP/compose.yml" "$COMPOSE_DIR/docker-compose.netflow-agent.yml"
install -d /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/mm-netflow.conf <<'EOF'
[Unit]
Wants=[email protected]
After=[email protected]
EOF
systemctl daemon-reload
systemctl enable [email protected]
if ! ip link show dev wg-flow >/dev/null 2>&1; then
systemctl start [email protected]
else
ip link set dev wg-flow up
fi
cd "$COMPOSE_DIR"
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d netflow-agent
AGENT_READY=false
for attempt in $(seq 1 60); do
if [[ "$(docker inspect --format '{{.State.Health.Status}}' mmapp-netflow-agent)" == healthy ]]; then
AGENT_READY=true
break
fi
sleep 1
done
[[ "$AGENT_READY" == true ]] || { echo "Агент не готов. Проверьте docker logs mmapp-netflow-agent; backend не пересоздан." >&2; exit 1; }
docker compose "${COMPOSE_ARGS[@]}" -f docker-compose.netflow-agent.yml up -d --no-deps --force-recreate backend
echo "Агент установлен. Используйте те же -f файлы при дальнейших операциях Compose:"
printf 'docker compose '; printf '%q ' "${COMPOSE_ARGS[@]}" -f "$COMPOSE_DIR/docker-compose.netflow-agent.yml"; echo 'up -d'
echo "В MM обновите статус хоста и нажмите «Применить настройки хоста»."
+201
View File
@@ -0,0 +1,201 @@
import base64
import copy
import json
import os
from pathlib import Path
import tempfile
import unittest
import shutil
import uuid
from unittest.mock import patch
import agent
def key(n):
return base64.b64encode(bytes([n]) * 32).decode()
class ControllerTest(unittest.TestCase):
def setUp(self):
# Windows Python 3.13's TemporaryDirectory mode 0700 excludes the restricted sandbox token.
base = Path.cwd() / "tmp" if os.name == "nt" else Path(tempfile.gettempdir())
base.mkdir(exist_ok=True)
self.root = base / ("flow-agent-test-" + uuid.uuid4().hex)
self.root.mkdir()
def cleanup():
if self.root.resolve().parent != base.resolve():
raise RuntimeError("Unexpected test directory")
shutil.rmtree(self.root)
self.addCleanup(cleanup)
self.addCleanup(patch.stopall)
patch.object(agent, "CONFIG", self.root).start()
self.interface = dict(PrivateKey=key(1), Address="10.255.254.1/24", ListenPort="51821", MTU="1420")
self.third = dict(PublicKey=key(3), AllowedIPs="192.0.2.5/32", PresharedKey=key(9), PersistentKeepalive="0")
self.desired = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739,
wgListenPort=51821, hostPublicKey=key(2), hostPrivateKey=key(1),
peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232")])
(self.root / "binding.json").write_text(json.dumps({k: self.desired[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
self.previous = agent.render(self.interface, [self.third])
(self.root / "wg-flow.conf").write_text(self.previous)
self.runtime = [copy.deepcopy(self.third)]
self.calls = []
def run(*args, input=None):
self.calls.append(args)
if args == ("wg", "pubkey"):
return key(2) if input == key(1) + "\n" else key(8)
if args == ("wg", "showconf", "wg-flow"):
return agent.render(self.interface, self.runtime, native=True)
raise AssertionError(args)
patch.object(agent, "run", run).start()
patch.object(agent, "live", self.live).start()
patch.object(agent, "sync", self.sync).start()
self.synced = []
def live(self):
return dict(interfaceUp=True, publicKey=key(2), listenPort=51821, addresses=["10.255.254.1/24"], peers=[]), copy.deepcopy(self.runtime)
def sync(self, text):
self.synced.append(text)
# Native configs don't include wg-quick's Address; simulate applying their peers.
_, self.runtime = agent.parse_conf(text.replace("ListenPort = 51821", "ListenPort = 51821\nAddress = 10.255.254.1/24"))
def test_apply_preserves_third_party_psk_and_is_idempotent(self):
self.assertFalse(agent.check(self.desired)["configMatches"])
self.assertTrue(agent.check(self.desired, apply=True)["configMatches"])
text = (self.root / "wg-flow.conf").read_text()
self.assertIn(key(3), text)
self.assertIn(key(9), text)
self.assertIn(key(4), text)
self.assertNotIn("Address", self.synced[0])
self.assertTrue(agent.check(self.desired)["configMatches"])
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), text)
if os.name != "nt":
self.assertEqual((self.root / "wg-flow.conf").stat().st_mode & 0o777, 0o600)
def test_runtime_only_peer_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="198.51.100.7/32", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertIn(key(6), (self.root / "wg-flow.conf").read_text())
def test_runtime_peer_without_allowed_ips_preserved(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="", PersistentKeepalive="0"))
agent.check(self.desired, apply=True)
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_third_party_runtime_settings_are_not_reset(self):
# Runtime may contain a roaming endpoint and intentionally unsaved settings.
self.runtime[0].update(Endpoint="198.51.100.2:50000", PersistentKeepalive="10")
agent.check(self.desired, apply=True)
third = next(p for p in self.runtime if p["PublicKey"] == key(3))
self.assertEqual(third["Endpoint"], "198.51.100.2:50000")
self.assertEqual(third["PersistentKeepalive"], "10")
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(3))["PersistentKeepalive"], "0")
def test_managed_roaming_endpoint_preserved_without_changing_desired_dns(self):
agent.check(self.desired, apply=True)
next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"] = "198.51.100.2:50000"
agent.check(self.desired, apply=True)
_, stored = agent.parse_conf((self.root / "wg-flow.conf").read_text())
self.assertEqual(next(p for p in stored if p["PublicKey"] == key(4))["Endpoint"], "router.test:13232")
self.assertEqual(next(p for p in self.runtime if p["PublicKey"] == key(4))["Endpoint"], "198.51.100.2:50000")
self.assertTrue(agent.check(self.desired)["configMatches"])
def test_down_interface_cannot_report_applied(self):
status, peers = self.live()
with patch.object(agent, "live", return_value=({**status, "interfaceUp": False}, peers)):
with self.assertRaisesRegex(agent.AgentError, "выключен"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_overlap_rejected_before_any_write(self):
self.runtime.append(dict(PublicKey=key(6), AllowedIPs="10.255.254.0/24"))
with self.assertRaisesRegex(agent.AgentError, "пересекаются"):
agent.check(self.desired, apply=True)
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
self.assertEqual(self.synced, [])
def test_different_interface_identity_rejected(self):
self.desired["hostPrivateKey"], self.desired["hostPublicKey"] = key(7), key(8)
with self.assertRaisesRegex(agent.AgentError, "существующего"):
agent.check(self.desired, apply=True)
self.assertEqual(self.synced, [])
def test_failed_apply_restores_file_and_runtime(self):
rollback = agent.render(self.interface, self.runtime, native=True)
with patch.object(agent, "sync", side_effect=[agent.AgentError("secret error"), None]) as sync:
with self.assertRaisesRegex(agent.AgentError, "предыдущая конфигурация восстановлена"):
agent.check(self.desired, apply=True)
self.assertEqual(sync.call_args_list[-1].args, (rollback,))
self.assertEqual((self.root / "wg-flow.conf").read_text(), self.previous)
def test_failed_rollback_is_explicit(self):
with patch.object(agent, "sync", side_effect=agent.AgentError("secret error")):
with self.assertRaisesRegex(agent.AgentError, "восстановление требует ручной проверки"):
agent.check(self.desired, apply=True)
def test_invalid_or_shell_input_rejected(self):
for change in [dict(flowListenPort=4740), dict(prefix="10.255.254.0/28"), dict(hostPrivateKey="$(id)"), dict(command="id"), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["0.0.0.0/0"])]), dict(peers=[dict(serverId=1, publicKey=key(4), allowedIps=["10.255.254.2/32"], endpoint="router.test:13232\nPostUp = id")])]:
with self.subTest(change=list(change)):
with self.assertRaises(agent.AgentError):
agent.check({**self.desired, **change}, apply=True)
self.assertEqual(self.synced, [])
def test_hooks_and_duplicate_fields_rejected(self):
for suffix in ("\nPostUp = id", "\nListenPort = 3", "\n[Interface]\nPrivateKey = secret"):
with self.assertRaises(agent.AgentError):
agent.parse_conf(agent.render(self.interface, []) + suffix)
def test_endpoint_change_requires_apply(self):
agent.check(self.desired, apply=True)
self.desired["peers"][0]["endpoint"] = "new-router.test:13232"
self.assertFalse(agent.check(self.desired)["configMatches"])
def test_command_errors_do_not_expose_keys(self):
with patch.object(agent.subprocess, "run", return_value=type("Result", (), {"returncode": 1, "stdout": key(1), "stderr": key(9)})()):
# Use the original function, not the fixture runner.
with self.assertRaisesRegex(agent.AgentError, "не выполнена") as error:
ORIGINAL_RUN("wg", "show")
self.assertNotIn(key(1), str(error.exception))
self.assertNotIn(key(9), str(error.exception))
ORIGINAL_RUN = agent.run
@unittest.skipUnless(os.environ.get("FLOW_AGENT_KERNEL_TEST") == "1", "Requires Linux kernel WireGuard and NET_ADMIN in an isolated container")
class KernelTest(unittest.TestCase):
def test_real_interface_apply_and_preservation(self):
with tempfile.TemporaryDirectory() as folder, patch.object(agent, "CONFIG", Path(folder)):
private = agent.run("wg", "genkey")
public = agent.run("wg", "pubkey", input=private + "\n")
other = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
desired_key = agent.run("wg", "pubkey", input=agent.run("wg", "genkey") + "\n")
interface = dict(PrivateKey=private, Address="10.255.254.1/24", ListenPort="51821")
third = dict(PublicKey=other, AllowedIPs="192.0.2.3/32")
path = Path(folder)
(path / "wg-flow.conf").write_text(agent.render(interface, [third]))
snapshot = dict(collectorIp="10.255.254.1", prefix="10.255.254.0/24", flowListenPort=4739, wgListenPort=51821,
hostPublicKey=public, hostPrivateKey=private, peers=[dict(serverId=1, publicKey=desired_key, allowedIps=["10.255.254.2/32"], endpoint="127.0.0.1:13232")])
(path / "binding.json").write_text(json.dumps({k: snapshot[k] for k in ("collectorIp", "prefix", "flowListenPort", "wgListenPort")}))
agent.run("ip", "link", "add", "dev", agent.IFACE, "type", "wireguard")
try:
agent.run("ip", "address", "add", "10.255.254.1/24", "dev", agent.IFACE)
agent.sync(agent.render(interface, [third], native=True))
agent.run("ip", "link", "set", "dev", agent.IFACE, "up")
before = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertTrue(agent.check(snapshot, apply=True)["configMatches"])
self.assertTrue(agent.check(snapshot)["configMatches"])
status, _ = agent.live()
self.assertEqual({p["publicKey"] for p in status["peers"]}, {other, desired_key})
self.assertNotIn(private, json.dumps(status))
after = json.loads(agent.run("ip", "-j", "link", "show", "dev", agent.IFACE))[0]["ifindex"]
self.assertEqual(before, after)
finally:
agent.run("ip", "link", "delete", "dev", agent.IFACE)
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
# Логи MikroTik
Раздел `/logs` использует общую панель выбора серверов, как `/filters` и `/firewall`.
История хранится в PostgreSQL; live читает уже сохранённые события через SSE.
## Включение
При следующем запуске backend автоматически применяется миграция `0014_router_logs.sql`.
В «Сбор данных → Логи MikroTik» администратор включает сбор. По умолчанию он выключен,
фоновый интервал — 10 секунд, хранение — 14 дней. Настройки требуют `mm:settings:admin`;
журнал, контекст, статистика и экспорт — `mm:servers:read` (также write/admin этой секции).
При открытом live выбранный включённый MikroTik опрашивается раз в 2 секунды.
Вкладки разделяют опрос; одновременно опрашиваются не более четырёх устройств.
Целевая задержка при штатной нагрузке — до 5 секунд. Ошибки сети вызывают backoff до минуты.
«Все серверы», выключенные и удалённые устройства доступны в режиме истории.
Коллектор читает только buffer=memory и не меняет правила RouterOS. После очистки,
перезагрузки или переполнения буфера возможны пропуски. Потеря пересечения снимков
помечается как возможный разрыв без выдуманного числа пропущенных событий. Время без
явного часового пояса не интерпретируется как UTC: журнал использует отмеченное время
получения и сохраняет исходную строку RouterOS.
События удаляются пакетами по времени получения. Очистка продолжает работать и при
выключенном сборщике. Дедупликация хранит SHA-256 отпечатки, а не копии сообщений.
Удаление устройства не удаляет его историю; повторно открыть её можно по ссылке
`/logs?server=<id>` или через общий журнал. Новые таблицы входят в полный `pg_dump`.
Планировщик и фоновые задачи ожидаются, а SSE закрываются перед обслуживанием БД.
Перед закрытием поток получает `reset`: интерфейс предлагает заново загрузить историю
после обслуживания, чтобы не продолжать старый курсор поверх восстановленной БД.
## Интерфейс и ReUI PRO
Сохранены компоненты и стиль проекта base-nova. Исходники блоков получены из
лицензионного реестра ReUI и адаптированы под RouterOS; демонстрационные данные,
изменения инфраструктуры и workflow review из блоков не используются.
- Основа таблицы, toolbar и графика: [Infra Audit Log](https://reui.io/blocks/solutions/dev-ops/solution-dev-ops-2), [превью](https://reui.io/preview/base/solution-dev-ops-2).
- Панель записи, исходные поля и контекст: [Audit Event Detail](https://reui.io/blocks/application/sheet/sheet-14), [превью](https://reui.io/preview/base/sheet-14).
- [Data Grid API](https://reui.io/docs/components/base/data-grid), [превью](https://reui.io/components/data-grid).
- [Filters API](https://reui.io/docs/components/base/filters), [превью](https://reui.io/components/filters).
- [Frame API](https://reui.io/docs/components/base/frame), [превью](https://reui.io/components/frame).
- [Badge API](https://reui.io/docs/components/base/badge), [превью](https://reui.io/components/badge).
Фильтры между полями соединяются AND, значения уровня/topics — OR. Поиск сообщения
регистронезависимый, буквальный. «Ошибки входа» — явно обозначенный текстовый шаблон.
Представления хранятся локально с привязкой к пользователю и backend. Экспорт CSV/JSON
применяет те же фильтры; выборки свыше 50 000 записей отклоняются с предложением сузить
период. Формулы spreadsheet в CSV нейтрализуются.
При прокрутке и открытии записи обновление видимой таблицы приостанавливается, но
сбор продолжается. Буфер отображения ограничен 5 000 строками; более ранние записи
доступны через период/экспорт. Смена сервера/источника/фильтров отменяет старые запросы.
## Проверки
- `npm --prefix backend run build` — contracts и backend.
- `npm --prefix backend run test:logs` — дедупликация, нормализация, фильтры, CSV, JWT/RBAC и REST-совместимость терминала.
- `npm --prefix backend run test:logs:pg` — интеграция с PostgreSQL и локальным HTTP-стендом RouterOS; используется отдельная случайная схема, которая удаляется после проверки.
- Для нагрузочной SQL-проверки установить `LOGS_PERFORMANCE=1` перед `test:logs:pg`: миллион событий, курсорная история и агрегация.
- `npm run test:logs` — Playwright: выбор серверов, детали, URL/поиск, экспорт, live-пауза, устаревшие ответы, 5 000 строк, mobile/desktop, светлая/тёмная темы.
Браузерный профиль использует Webpack и отдельный каталог `tmp/next-logs-test`, чтобы
не конфликтовать с кэшем запущенного dev-сервера. SQL-профиль требует доступного
`DATABASE_URL` и права создать временную схему; при недоступной БД завершится ошибкой,
а не выдаст пропуск за успешную проверку.
+178
View File
@@ -0,0 +1,178 @@
# Автоматическое подключение NetFlow
`mmapp-netflow-agent` управляет только `wg-flow` на том же Debian/Ubuntu-хосте,
где работает Docker Engine. Используются WireGuard ядра Linux и обычный Docker с
root-правами. Rootless Docker и Docker Desktop не поддерживаются этим режимом.
## Production-состав
С Compose `deploy/docker-compose.cdn-mm.yml` и дополнением агента работают семь контейнеров:
| Контейнер | Назначение |
| --- | --- |
| cdn-mm-traefik | HTTPS и маршрутизация |
| cdnmanager | CDN Manager |
| mmapp-frontend | Интерфейс MM |
| mmapp-backend | API, настройка MikroTik и приём IPFIX |
| mmapp-postgres | Настройки и потоки |
| mmapp-updater | Существующее обновление контейнеров MM |
| mmapp-netflow-agent | Применение пиров WireGuard на хосте |
Собственный systemd-сервис не нужен. Стандартный `wg-quick@wg-flow` поднимает
интерфейс до Docker: backend публикует IPFIX только на его адресе.
```mermaid
flowchart TB
User[Пользователь] -->|HTTPS| Proxy[Traefik]
subgraph Host[Production Linux]
subgraph Docker[Docker Compose]
Proxy --> Frontend[frontend]
Proxy --> CDN[CDN Manager]
Frontend -->|API| Backend[backend + IPFIX collector]
Backend --> PG[PostgreSQL]
Backend <-->|HTTP через Unix socket bind mount| Agent[netflow-agent: host network + NET_ADMIN]
Updater[updater] -.-> Backend
Updater -.-> Frontend
end
Agent -->|atomic write| Conf[wg-flow.conf]
Agent -->|wg syncconf| WG[wg-flow: интерфейс хоста]
Conf --> Boot[wg-quick при загрузке]
Boot --> WG
WG -->|Docker UDP bind на WG-IP| Backend
end
Backend -->|RouterOS API| Router[MikroTik]
Router <-->|WireGuard: внутри IPFIX| WG
```
Агент не обрабатывает потоки: при остановке/пересоздании контейнера интерфейс
и приём IPFIX продолжают работать. `NET_ADMIN` вместе с host network позволяет
управлять сетью хоста; API ограничено фиксированным интерфейсом и структурированными
настройками. Агент не получает Docker socket, `privileged`, корневую ФС хоста,
право загрузки модулей или TCP API.
## Однократная установка
1. Обновить MM до версии с агентом. Образ агента должен быть опубликован CI
в том же реестре; production-хост должен быть авторизован через `docker login`.
2. В «Сбор данных → NetFlow» создать ключи хоста, открыть «Файлы хоста» и скачать
`install-netflow-agent.sh` из вкладки «Агент Docker».
3. Выполнить на Linux-хосте:
```bash
sudo bash install-netflow-agent.sh /opt/cdn-mm
```
Файл содержит приватный ключ. Хранить его как секрет, не коммитить.
При необходимости образ можно задать явно:
```bash
sudo MM_NETFLOW_AGENT_IMAGE=git.shx.one/denozord/mikrotikmanager-netflow-agent:VERSION \
bash install-netflow-agent.sh /opt/cdn-mm
```
4. В MM обновить состояние хоста и применить настройки. Затем подключать
jump-host из раздела трафика: пиры применяются автоматически без перезапуска WG.
Альтернатива из checkout: скачать `wg-flow.conf` из MM и выполнить
`sudo bash deploy/netflow-agent/install.sh /absolute/path/wg-flow.conf /opt/cdn-mm 4739`.
Третий аргумент — фактический IPFIX-порт из настроек.
Установщик сначала проверяет конфигурацию и Compose. Он сохраняет существующие
пиры и делает резервную копию конфигурации; при несовпадении ключа, адреса или порта
останавливается. Hooks, SaveConfig и нестандартные параметры wg-quick требуют
ручной проверки и не принимаются. Интерфейс должен иметь IPv4 /24;
пиры MM получают собственные /32 в этой сети.
Создаются:
- `data/netflow-wireguard/wg-flow.conf` и `binding.json` с правами `0600`;
- ссылка `/etc/wireguard/wg-flow.conf` на постоянную конфигурацию;
- `docker-compose.netflow-agent.yml`, не заменяющий пользовательский override;
- drop-in `/etc/systemd/system/docker.service.d/mm-netflow.conf` для порядка
запуска `wg-quick → Docker`;
- общий каталог `data/netflow-agent-socket` для Unix-сокета, подключённый в backend только для чтения. Bind mount сохраняет этот режим при пересоздании backend существующим updater.
Установщик однократно пересоздаёт backend и не перезапускает Docker или Traefik.
Он не изменяет firewall хоста. Существующий публичный или несовместимый IPFIX bind
в Compose нужно исправить перед повторным запуском. Не публиковать IPFIX на `0.0.0.0`.
## Эксплуатация
При следующих операциях Compose сохранять все файлы дополнений:
```bash
cd /opt/cdn-mm
docker compose -f docker-compose.yml -f docker-compose.override.yml \
-f docker-compose.netflow-agent.yml up -d
```
Если `docker-compose.override.yml` отсутствует, убрать соответствующий `-f`.
Установщик печатает команду для фактического набора файлов.
Образ агента обновляется явно через Compose; существующий updater не включает
его в автоматические цели. При пересоздании backend updater сохраняет его mounts.
Просмотр: `GET /api/traffic/flow/host-status`, право `mm:traffic:read`.
Повторное применение: `POST /api/traffic/flow/host-reconcile`, право `mm:traffic:write`.
Статус не содержит приватных/preshared ключей. `lastHandshakeAt` — Unix seconds
или `null`; факт применения настроек не означает handshake или получение IPFIX.
Время последнего IPFIX-пакета в панели относится ко всему коллектору.
Подключения и изменения настроек сериализуются PostgreSQL advisory lock.
После рестарта backend повторно применяет сохранённые пиры. При конфликте или
недоступности агента приложение и текущий приём продолжают работать.
Если MikroTik уже настроен, но хост не применён, результат подключения содержит
`ok: false`, `stage: host-pending`, `hostError`: нажать «Применить настройки хоста».
При ошибке во время настройки роутера повторить подключение; его изменения
не откатываются автоматически.
Агент сохраняет сторонние persistent и runtime-only пиры, проверяет пересечения
AllowedIPs, атомарно записывает конфигурацию и применяет её через `wg syncconf`.
При сбое восстанавливает предыдущий файл и runtime; сбой восстановления сообщается
отдельно. Добавление/обновление пиров не меняет адреса, маршруты и интерфейс.
Первая версия не удаляет туннели. Адрес коллектора, префикс и порты закреплены
установкой: изменение требует согласования настроек MM, WG и Compose вручную и
повторного запуска установщика. Автоматическая смена адреса или ротация ключа
не выполняется. Другие установки могут продолжать пользоваться ручным экспортом
без `FLOW_AGENT_SOCKET`.
## Проверки
```bash
npm --prefix backend run test:netflow-settings
npx playwright test -c playwright.netflow-agent.config.ts
python3 -m unittest discover -s deploy/netflow-agent -p 'test_*.py' -v
bash -n deploy/netflow-agent/install.sh
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
```
Последняя проверка использует отдельное сетевое пространство контейнера, проверяет
настоящий kernel WireGuard, сохранение стороннего пира и неизменность интерфейса.
Она не заменяет приёмку на стенде с двумя MikroTik: handshake/IPFIX от каждого,
сохранение первого соединения при добавлении второго, перезапуск агента/backend
и холодная загрузка Linux-хоста с проверкой UDP bind.
### Приёмка на стенде — 2026-10-16
На `161.104.46.222` проверены временные контейнеры новой ветки с отдельной БД.
15 тестов агента прошли с настоящим kernel WireGuard; 32 профильных скрипта
и две отдельные SQL-проверки (monthly и IPv6 UDP/worker) завершились успешно.
Оба MikroTik (`194.226.121.150`, `104.165.244.7`) подключены через API: подтверждены
handshake, сохранение первого туннеля при добавлении второго и повторное подключение
без дубликатов. В БД записаны IPFIX-потоки обоих MikroTik и синтетического Linux
экспортёра через WireGuard. Реальные перезапуски контейнеров агента и backend
сохранили соединения, приём пакетов продолжился.
При проверке исправлен API настроек: счётчики и время последнего пакета теперь
читаются из работающего коллектора. После исправления полный живой сценарий повторён.
Настройки обоих маршрутизаторов восстановлены; временные контейнеры, интерфейс,
БД/роли и приватные файлы удалены. Основные сервисы остались на прежних образах.
Установщик с интеграцией `wg-quick`/systemd и холодная перезагрузка хоста на этом
стенде не выполнялись. Образы собраны поверх существующего runtime backend;
полная release-сборка frontend и публикация образов в эту проверку не входили.
Результаты, исходные неудачные попытки и границы —
[отчёт приёмки](ux-ui/acceptance/netflow-agent-2026-10-16.json).
+91 -1
View File
@@ -1,6 +1,12 @@
# UX/UI: журнал реализации
Актуальное состояние 2026-10-15: 264 — лабораторная приёмка основного UX/UI-плана завершена в документированных границах стенда. Приняты1396 основных UI и221 отдельных context-сценарий;120 live default states30 экранов. Runtime backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Публикация main/2.0.0 отдельно разрешена владельцем; [описание релиза](releases/2.0.0.md).
Текущее продолжение 2026-10-16: Production276 — по разрешению владельца включён tcp-syncookies на arn-gw01; повторное чтениеtrue.8/8 HTTP200, TCP21–45мс против1–5с до изменения, полный запрос143–334мс. За2мин29с штатные5 traffic/5 gre_bgp/10 resources/1 internet_path без ошибок. Других изменений и перезапуска не было; краткая проверка не доказывает отсутствие проблем при любой нагрузке.
Продолжение 2026-10-16: Production274 — по разрешению владельца на nov-gw01 выключены FTP/Telnet и оставлены выключенными по отдельному подтверждению. За21 минуту штатного опроса42 traffic/42 gre_bgp/4 internet_path/84 uptime_resources успешны, ошибок нет; новые подключения21/23 отклоняются. Причинная связь прошлых401/500 с подбором пока не доказана. Приложение/пароли/firewall не менялись; подсказки273 остаются локальными.
Актуальное состояние 2026-10-16: Production272 — исправления268/270/271 опубликованы в main, CI2468 success, production backend/frontend обновлены до2.0.2 и healthy. В HTTP/UI подтверждены рекурсивные маршруты всех3 JH; Filters без ложного missing-route warning;4 узла карты и новая легенда. PostgreSQL не перезапускался. Read-only DNS и дальнейшая проверка трафика остаются отдельными задачами.
Предыдущее состояние 2026-10-15: Production269 — по отдельному разрешению обновлён production frontend до2.0.1; в браузере подтверждены3JH/1EN и25GRE в данных. Filters268 остаётся локальным;6 профильных E2E/TypeScript passed. Карта и миграция retention опубликованы в main; исправлен CI PostgreSQL, backend-test2467/8417 success. Карта принята в production с ограничением read-only DNS; Filters ещё не развёрнут. Online-индекс266 применён отдельно с разрешения владельца; нагрузка БД снизилась в двух повторных замерах. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +44,90 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
## Production276 — SYN cookies arn-gw01 и повторная проверка (2026-10-16)
После прямого разрешения владельца проверены ID1/host и false;16 октября09:55:01 по Новосибирску выполнен только POST /ip/settings/set с tcp-syncookies=true. Предварительный PATCH singleton-раздела отклонён400 без применения; использована штатная команда set с тем же единственным параметром. Повторные чтения и identity подтвердили результат. Пароли, службы, firewall, маршруты, приложение, БД и контейнеры не менялись; перезапуска не было.
8 последовательных контрольных HTTP200: TCP21–45мс против1042–5136мс, полный HTTPS143–334мс против1162–5274мс в исходных4 замерах.45-секундный захват только target443 SYN/SYN-ACK:42 пакета, повторов SYN в полученном выводе нет, kernel dropped0, захват завершён. К09:57:30 штатные задания после применения:5 traffic/5 gre_bgp/10 resources/1 internet_path успешны, ошибок0, максимальные длительности1091/426/854/284мс. Финальный readbacktrue, CPU6%, uptime14m19s. В доступном неполном кольце логов новых SYN warning нет; старые события не удалялись.
В кратком сравнении улучшение подтверждено, но источник всех потерь и поведение при другой нагрузке не доказаны. [Измерения и откат](../deploy/PRODUCTION-ARN-276.md). Диагностика завершена, фоновых циклов/захвата нет. Документация сохраняется локальным коммитом без push; код не менялся, повторная сборка не требовалась.
## Production275 — диагностика SYN и задержек arn-gw01 (2026-10-16)
На ID1/arn-gw01 воспроизведён REST timeout10с. Четыре последовательных HTTP200 показали DNS2–6мс, TCP1.042–5.136с, TLS ещё99–145мс, ответ после TLS19–21мс.20-секундный захват SYN/SYN-ACK только target443 на production-хосте подтвердил повторные SYN штатного приложения и ожидание ответа2.044/4.122с; нормальный SYN-ACK18–21мс. Место потери между хостом и обработкой CHR не установлено.
RouterOS7.24.5, uptime5m41s/CPU6% в одном замере; оба warning80/443 через7с после первой записи запуска, позднее есть смена часов Europe/Stockholm. Массовую текущую атаку это не доказывает. tcp-syncookies=false; ограничения input80/443 доверенными источниками в прочитанных правилах нет, RAW пустой. Итоговый conntrack-снимок:4 локальных80/443 TCP,2 от приложения/2 других, не свидетельство большого потока. Порты и адреса этой версии считались отдельными полями.
[Протокол и подготовленный следующий шаг](../deploy/PRODUCTION-ARN-275.md). Предложено отдельно согласовать изменение только tcp-syncookies с чтением результата и коротким повторным замером; сеть провайдера оно не исправляет. Пароли, RouterOS, firewall, приложение и контейнеры не менялись. Диагностика завершена, tcpdump остановился по20с timeout; код не менялся, сборка/тесты повторно не запускались. Локальный коммит без push.
## Production274 — отключение FTP/Telnet и наблюдение (2026-10-16)
После явного разрешения владельца на ID3/nov-gw01 повторно проверены службы ftp*1/21 и telnet*0/23, затем16 октября01:33:45 по Новосибирску применено только disabled=true. Повторное чтение подтвердило результат; www/www-ssl/winbox/api/api-ssl сохранены включёнными. Владелец отдельно подтвердил оставить FTP/Telnet выключенными. Пароли, firewall, маршрутизация, задания RouterOS и контейнеры не менялись.
До01:54:44 наблюдался обычный опрос:42 traffic/42 gre_bgp/4 internet_path/84 uptime_resources успешны, ошибок0; максимальные длительности2278/910/809/1937мс. Финальный REST и uptime подтверждают доступность без перезапуска. На01:55:47 новые TCP-подключения21/23 получили ECONNREFUSED. Остаются6 старых динамических Telnet строк: принудительное завершение соединений не выполнялось. В выборках логов2 отказа Winbox с несохранёнными источниками;4 пропуска кольца из-за scheduler-изменений, поэтому непрерывное отсутствие атак не утверждается. Ошибки401/500 прекратились ещё до отключения; причинная связь не доказана. Наблюдение завершено, фоновых диагностических циклов не осталось.
По дополнительному вопросу владельца проверена production PostgreSQL только чтением: standby=false, wal_level=replica, подключённых реплик/slots/subscriptions0, synchronous_standby_names пустой. Репликация в конфигурации проекта и активном состоянии не обнаружена. [Процедура, результат и откат](../deploy/PRODUCTION-ROUTEROS-274.md). Документация сохраняется локальным коммитом, без push/deploy; исправление подсказок273 не развёрнуто.
## Production273 — диагностика отказов входа RouterOS (2026-10-16)
На nov-gw01 подтверждены соседние500 `too many concurrent logins (6)` и401; последние в01:11:40–44 по Новосибирску. Тот же сохранённый пароль работает, full/api/rest-api разрешены. На01:27 последние15 минут: traffic30/30, gre_bgp30/30, internet_path3/3 успешны. Наблюдаются внешние попытки входа через открытые FTP/Telnet и7 внешних Telnet-соединений, а также частые изменения от локального evofw-sync. Их причинная связь с отказами пока не доказана; насыщение входа нельзя приравнивать к max-sessions20 или старым user/active записям. Production, RouterOS и пароли не менялись.
Локально исправлены подсказки:401 не объявляет пароль неверным; точный500/detail распознаётся как лимит одновременных входов. Исходные status/path/body сохранены, повторов операций нет.12 HTTP-проверок, профиль ros-file, backend build, scoped ESLint и diff check прошли. [Протокол и процедура](../deploy/PRODUCTION-ROUTEROS-273.md). Без push/deploy. Следующий шаг — после отдельного разрешения временно отключить только FTP/Telnet на ID3 и сравнить обычный опрос минимум за два периода продления REST-логина. Это ещё не выполненное исправление первопричины.
## Production272 — публикация и приёмка v2.0.2 (2026-10-16)
По прямому разрешению владельца подготовленные коммиты268/269/270/271 отправлены в main до e6a2e400. Все7 jobs CI2468 success; v2.0.2 опубликован. Проверены OCI version/revision и digest обоих образов, затем обновлены только backend и frontend через Compose --no-deps --pull never. Backend healthy с00:41:33, frontend healthy с00:42:11 по Новосибирску; PostgreSQL container ID/StartedAt и SHA256 Compose сохранены. RouterOS-конфигурация и права не менялись; автоматического prune не было.
Обычный HTTP после обновления:3 JH,4/3/5 маршрутов,200/live=true/stale=false, разовые задержки1048/2575/736мс. Каталоги шлюзов200,9/7/8 вариантов за1167/3800/993мс. В браузере до обновления воспроизведена ошибка, после последовательно выбраны все3 JH и подтверждены4/3/5 endpoints без ошибки; несколько endpoints могут группироваться в одну строку. Filters выбранного московского JH:10 правил,0 ложных «нет строки в списке», /32 подписи видны. Карта:3JH/1EN, новая графическая легенда; клик даёт aria-pressed=true, outline=none и focus-ring opacity=0. DNS read-only403 mm:network:write остаётся;25GRE загружены,3 линии отрисованы, полная FQDN-связность не принята.
[Процедура и откат](../deploy/PRODUCTION-RELEASE-272.md), [анонимизированный протокол](ux-ui/acceptance/prod272-release.json); приватные скриншоты tmp/prod272-*.jpg вне Git. Формы записи/удаление в production не выполнялись. Протокол сохраняется локально без нового push, чтобы не запускать дополнительный релиз только документацией. Следующие отдельные задачи: read-only DNS, оценка поведения легенды рядом с открытыми деталями на узком холсте, независимая проверка трафика и остальных тайм-аутов; общая production-готовность всех внешних сценариев этим блоком не объявляется.
## Production271 — чтение рекурсивных маршрутов JH (2026-10-16)
Production GET /recursive-routes/catalog на двух московских JH возвращал503 «Не подтверждены параметры управляемого маршрута», на новосибирском наблюдался отдельный10с timeout. Проверка native RouterOS чтением: список и GET конкретной строки не содержат disabled/check-gateway; /ip/route/get подтверждает disabled=false, незаданный check-gateway возвращает[]. Строгий parser сохранён; добавлено дочитывание отсутствующих полей только для собственных маршрутов, максимум2 запроса одновременно. Только успешный пустой get check-gateway означает none; null/неверный envelope/ошибка не становятся defaults. Explicit invalid значения не заменяются.
Нагрузка чтения: полные таблицы12441/24864/12451 строк занимали2,4/8,0/2,1с. Каталог теперь использует штатный POST print с .query=[comment] (все комментарии, включая старые метки и неподдерживаемые owned строки), а не static-only фильтр, который мог бы скрыть неподдерживаемый объект. Отбор вернул12/13/7 строк за0,5/1,7/0,5с. Каталог шлюзов сохраняет все маршруты, но запрашивает только .id/gateway/active. Тип POST body расширен под штатные строковые массивы query/proplist, сериализация проверена настоящим локальным HTTP.
Новый код в отдельном процессе на production проверен только чтением, без замены работающего backend:4/3/5 собственных маршрутов успешно разобраны за1,026/3,225/0,643с с7/4/3 дочитываниями. RouterOS-конфигурация не менялась, saveObserved/применение не вызывались. Это замер чтения/разбора, не всего UI; отдельная загрузка шлюзов в него не включена. Большой первоначальный tool input отклонён автоматической проверкой по размеру; читаемый скомпилированный скрипт передан частями через stdin, без файлов/секретов на сервере.
Проверки:4 backend scripts recursive-routes passed;11 HTTP client tests passed;15 page E2E passed; backend build/scoped ESLint/diff check passed. В HTTP tests подтверждены восстановление omitted fields и503 без записи cache при ошибке get. [Анонимизированный протокол](ux-ui/acceptance/prod271-recursive.json), [официальный REST API](https://manual.mikrotik.com/docs/developer-guides/rest-api/). Изменение локальное, без push/deploy. Следующий шаг — согласовать публикацию готовых268/270/271 и обновление backend/frontend; текущая production-страница до этого сохраняет прежнюю ошибку. Остальные задержки/тайм-ауты не объявляются устранёнными.
## Production270 — легенда и контуры фокуса карты (2026-10-15)
По замечанию владельца выполнено сравнение с последним доступным тегом v1.42.0. Там легенда была раскрытой графической панелью, а выбранный узел имел полупрозрачный пунктирный круг. Текущий свёрнутый текстовый details заменён компонентом NetworkMapLegend: образцы статусов, JH/EN/HR, формы WAN/сервисов, GRE/IPsec/NetFlow/модель WAN→JH, пояснения RTT/неизвестных значений и различия потока и ёмкости. На ПК раскрыта, на телефоне изначально свёрнута, содержимое ограничено высотой со скроллом.
Прямоугольный outline по SVG bounding box заменён на focus-visible контур по форме устройства/WAN/сервиса/метрики. Мышь его не показывает, Tab/Enter работают; выделение узла сохранено с opacity0.45 как в1.42. Контуры фокуса удаляются из клона при экспорте SVG, чтобы CSS-зависимое скрытие не создавало лишние обводки в файле. ReUI-примитивы не перерабатывались; Badge используется штатный, цвета — существующие semantic tokens.
Проверки:13 прежних data E2E и4 новых UI E2E passed (legend desktop/mobile, pointer/keyboard focus, экспорт). TypeScript и scoped ESLint passed. Первый прогон выявил неточность текстового locator вокруг Badge; подпись выделена отдельным span и сценарий повторно прошёл. Скриншоты ПК/телефона просмотрены, после просмотра мобильная легенда сделана свёрнутой. Превью tmp/map270-desktop.png и tmp/map270-mobile.png вне Git. Финальная семантическая правка цвета образца GRE не меняет геометрию/поведение. Изменения локальные, без push/deploy; production остаётся2.0.1 из269. Следующий шаг — публикация подготовленных Filters268 и визуального патча270 после разрешения владельца; отдельно остаются read-only DNS и трафик.
## Production269 — фактическое обновление frontend и узлы карты (2026-10-15)
По повторной жалобе подтверждено: работающий frontend всё ещё2.0.0/72bf648c, хотя все jobs CI2467 завершились success и2.0.1 опубликован. API принимает исправленная схема4servers/25GRE/0probes, разовые задержки171/498/56мс. После отдельного разрешения владельца обновлён только frontend по проверенному digest2.0.1/042407f0; новый контейнер healthy, backend/PostgreSQL ID и StartedAt сохранены. Публичный manifest2.0.1 и браузер подтвердили4 узла (3JH/1EN),25GRE в данных и3 отображённые связи. Ошибка первичной валидации исчезла. Права read-only блокируют DNS resolve-hosts (mm:network:write), поэтому все линии/FQDN ещё не подтверждены; права не менялись. [Процедура и откат](../deploy/PRODUCTION-FRONTEND-269.md), приватный скриншот tmp/prod-map269.jpg вне Git. Блок268 Filters остаётся локальным и в этом обновлении отсутствует. Следующее действие — отдельная проверка read-only разрешения DNS и продолжение трафика; готовый Filters-патч требует публикации.
## Production268 — рекурсивные шлюзы Filters после обновления каталога (2026-10-15)
По production-скриншоту Filters показывает «рекурсивный маршрут (нет строки в списке)», хотя каталог формы содержит шлюз. Найден механизм: legacy GET /recursive-routes пересоздаёт строки recursive_routes, а правила могут сохранять прежние rec:id; таблица допускала поиск по hop только при пустой ссылке. Для явной рекурсивной ссылки с отсутствующим ID теперь используется уже имеющийся выбор активного маршрута по dst-address/hop и минимальной distance в каталоге выбранного устройства. Редактирование восстанавливает актуальный rec:id и выделение карточки. Именованный GRE не переопределяется по совпавшему IP; при реальном отсутствии маршрута предупреждение сохранено. Точная ссылка по ID сохраняет приоритет.
6 E2E passed: две новые регрессии и существующие проверки gateway failure/save. Новый сценарий проверяет отображение, выделение, отсутствие записи до подтверждения и payload с новым rec:id; второй — настоящий orphan и GRE с совпавшим IP. TypeScript passed; scoped ESLint0 errors/11 прежних unused-import warnings в filters/page.tsx. Непривилегированный первый запуск E2E не поднял доступный локальный сервер и остановлен; проверки выполнены повторно с разрешением запуска локального сервера/браузера. Backend и production-конфигурация не менялись; механизм пересоздания cache ID этим frontend-патчем не перерабатывается. Исправление сохранено локально, без push/deploy. Следующий шаг — опубликовать этот патч и проверить production Filters после обновления frontend.
## Production267 — подключение PostgreSQL в CI (2026-10-15)
После push main backend-test2466/8410 завершился ECONNREFUSED: PostgreSQL публиковался на loopback Docker-хоста, но тестовый процесс работал в отдельном контейнере Gitea runner. Прежние SQL-тесты пропускали подключение; новый обязательный retention-тест выявил проблему. PostgreSQL теперь присоединяется к сети контейнера задания и доступен по уникальному DNS alias; для host runner сохранён вариант с назначенным Docker портом. Удаление общего mm-pg исключено, cleanup затрагивает только контейнер текущего запуска. Перед тестами выполняется SELECT1 из процесса задания; REQUIRE_TEST_POSTGRES=1 запрещает скрытый skip при недоступности/ошибке bootstrap.
Workflow YAML/scoped ESLint/diff check прошли. Проверены оба режима helper на недоступном localhost-порту: локально false/skip, в обязательном режиме исходная ошибка подключения выходит наружу. После push042407f0 [backend-test2467/8417](https://git.shx.one/denozord/MikrotikManager/actions/runs/2467/jobs/8417) завершён success, включая retention20тыс. строк; обязательное подключение из тестового процесса подтверждено. Production этим блоком напрямую не изменялся.
## Production266 — online-индекс retention и проверка нагрузки (2026-10-15)
После прямого разрешения владельца только на индекс и проверку нагрузки в14:55:45 UTC запущен CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs(finished_at). Завершён успешно, indisvalid/indisready=true. В15:01:30 UTC индекс23МБ,697 использований; план эквивалентного SELECT сменился с Parallel Seq Scan на Bitmap Index Scan. PostgreSQL CPU66,51% →0,16% →0,13%; секундные vmstat iowait29–34% →0–5% →0–1%, CPU idle0% →74–94% →94–97%. Наблюдения ограничены несколькими минутами обычной нагрузки и не доказывают устранение каждого тайм-аута RouterOS.
Добавлены миграция0013 и индекс в Drizzle schema, автоматический SQL-тест в test:pg, [процедура](../deploy/PRODUCTION-RETENTION-INDEX.md) и [протокол](ux-ui/acceptance/prod266-retention.json). Фактический тест выполнен на отдельном тестовом Debian:20тыс. временных строк, повторный запуск миграции, валидный индекс, смена плана, сохранение результата; fixture откатан. Bootstrap/public-таблицы тест не меняет. Backend build/scoped ESLint passed. Production-контейнеры не перезапускались; ручного удаления данных и изменения срока retention не было. Приложение не развёртывалось, push отсутствует. Следующее действие: завершить независимую проверку расчётов трафика и подготовить обновление REST history; применение frontend-патчей к production требует отдельного разрешения.
## Production265 — исчезновение узлов карты и первичная диагностика (2026-10-15)
Через выданный read-only аккаунт подтверждён production v2.0.0. Карта отклоняет все4 устройства: API polledAt имеет штатный PostgreSQL timestamptz string (+00), frontend требует ISO. В локальной ветке исправлена только нормализация явного offset; отсутствующая зона и невозможная дата по-прежнему отклоняются. Сохранённый приватный ответ production теперь проходит:4 servers/25 GRE/0 probes. Регрессия сначала воспроизведена новым unit test; после исправления6 unit и13 map E2E прошли, TypeScript/scoped ESLint passed. Production не обновлялся; push этого исправления не выполнялся.
SSH только чтением:1 vCPU/1973MiB/no swap, PostgreSQL нагружает CPU/IO. Дважды наблюдался retention DELETE по scheduler_runs.finished_at: около1.06M live rows,853MB, нет индекса по этому столбцу. Исходник выполняет cleanup после каждого задания; EXPLAIN эквивалентного SELECT показывает Parallel Seq Scan. TCP443 из backend до4 устройств сейчас3–50ms; TLS/REST этим не приняты. Причинность всех10s тайм-аутов не установлена; очередь shared maxSockets2 входит в deadline. Трафик: live SSE актуален, REST history/list не обновляются автоматически; вычисления требуют отдельной проверки. [Анонимизированный протокол](ux-ui/acceptance/prod265-diagnostics.json). Приватные сырые ответы и учётные данные в Git не включаются. Следующее действие — подготовить и проверить индекс retention, продолжить трафик; применение DDL и развёртывание production требуют отдельного разрешения владельца, предоставившего доступ для чтения.
## Подготовка версии 2.0.0 (2026-10-15)
По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены.
+11
View File
@@ -2758,3 +2758,14 @@ Runtime backend ux-regression222/frontend ux-routing218. Latest backend25/UI52
2 roundtrip objects,12 unique API scenarios, synthetic native export/CHR contacts0. Own DB/roles/catalog/localfiles/currentobjects cleaned. Versioning Enabled:8versions+8markers removed,3 own prefixes remaining0; bucket configuration unchanged. Native backup/restore/live UI/смена storage origin/retention semantics остаются230. Private env удалены, secrets не в published files. [API](ux-ui/acceptance/s3-application229.json), [version cleanup](ux-ui/acceptance/s3-version-cleanup229.json).
Локальные .rsc после235: `/opt/mmapp-test/data/backups` → `/app/storage/backups`; см. [порядок переноса](../deploy/BACKUP-STORAGE.md). Две новые native copies после force-recreate проверены и удалены; десять старых catalog rows сохранены с зафиксированным отсутствием files.
## NetFlow host agent — 2026-10-16
Ветка `codex/netflow-host-agent`, исходники `db81cf0f` + исправление `0abdac82`.
Временные backend/agent и отдельные БД: kernel WG 15/15, профиль32 и отдельные
monthly/IPv6 UDP SQL passed. Оба CHR установили handshake и передали IPFIX в БД;
повторное подключение, сохранение стороннего пира и рестарты agent/backend прошли.
Найденные устаревшие счётчики API исправлены, живой сценарий повторён полностью.
Исходные Traffic Flow настройки восстановлены, own WG/containers/DB/roles/keys
удалены; четыре основных сервиса и их образы сохранены. Installer/systemd/cold reboot
не выполнялись. [Отчёт и границы](ux-ui/acceptance/netflow-agent-2026-10-16.json).
@@ -0,0 +1,402 @@
{
"date": "2026-10-16",
"stand": "161.104.46.222",
"branch": "codex/netflow-host-agent",
"baseCommit": "db81cf0f",
"testedSourceCommit": "0abdac82de3fd53fa0eeaae672a3681918191a9a",
"passed": true,
"scope": "Temporary backend/agent containers, separate PostgreSQL databases and host wg-flow; both authorized test MikroTik configured and restored.",
"build": {
"strategy": "Current compiled backend/contracts over existing backend runtime with matching application dependencies; test-only jose and transpiled tests added. Agent built from its Dockerfile.",
"uploadedFilesVerified": 1976,
"baseProductionCompiledFilesVerified": 1396,
"patchedCompiledRouteSha256": "99a5b78f0586cbc53c35ed4bcefadd4fdd6ecea6da075f936b7768c634b03dd1",
"patchedCompiledRouteMatchesLocal": true
},
"agentKernelTests": {
"passed": 15,
"failed": 0,
"skipped": 0,
"network": "Isolated container namespace",
"coverage": [
"Real kernel WireGuard apply",
"Foreign persistent/runtime peers",
"Unchanged interface identity",
"Validation/conflicts",
"Configuration and runtime rollback"
]
},
"sql": {
"primary": {
"database": "mm_accept_166_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-host-file-builders.test.js",
"durationMs": 196,
"skips": []
},
{
"path": "services/traffic-flow-key-execution.test.js",
"durationMs": 54,
"skips": []
},
{
"path": "routes/traffic-flow-settings.test.js",
"durationMs": 586,
"skips": []
},
{
"path": "plugins/auth.netflow-settings.test.js",
"durationMs": 665,
"skips": []
},
{
"path": "services/traffic-flow-agent-install.test.js",
"durationMs": 174,
"skips": []
},
{
"path": "services/traffic-flow-agent-client.test.js",
"durationMs": 260,
"skips": []
},
{
"path": "services/traffic-flow-operation-queue.test.js",
"durationMs": 54,
"skips": []
},
{
"path": "routes/traffic-flow-host.test.js",
"durationMs": 830,
"skips": []
},
{
"path": "routes/traffic-flow-query.test.js",
"durationMs": 1816,
"skips": []
},
{
"path": "services/traffic-flow-month.test.js",
"durationMs": 1146,
"skips": [
"Monthly SQL: skip (requires an owned isolated database)"
]
},
{
"path": "services/traffic-flow-parse.test.js",
"durationMs": 1174,
"skips": []
},
{
"path": "services/traffic-flow-ipv6-pipeline.test.js",
"durationMs": 1203,
"skips": [
"IPv6 UDP/worker/SQL: skip (requires an owned isolated database)"
]
},
{
"path": "services/traffic-flow-map-exporter.test.js",
"durationMs": 55,
"skips": []
},
{
"path": "services/traffic-flow-ifaces.test.js",
"durationMs": 64,
"skips": []
},
{
"path": "services/traffic-flow-ifindex.test.js",
"durationMs": 59,
"skips": []
},
{
"path": "services/traffic-flow-dedup.test.js",
"durationMs": 55,
"skips": []
},
{
"path": "services/traffic-flow-planes.test.js",
"durationMs": 51,
"skips": []
},
{
"path": "services/traffic-flow-ip.test.js",
"durationMs": 75,
"skips": []
},
{
"path": "services/traffic-flow-dest.test.js",
"durationMs": 1836,
"skips": []
},
{
"path": "services/traffic-flow-classify.test.js",
"durationMs": 903,
"skips": []
},
{
"path": "services/traffic-flow-ripe.test.js",
"durationMs": 1440,
"skips": []
},
{
"path": "services/traffic-flow-brands.test.js",
"durationMs": 93,
"skips": []
},
{
"path": "services/traffic-flow-ingest.test.js",
"durationMs": 5340,
"skips": []
},
{
"path": "services/traffic-flow-analytics.test.js",
"durationMs": 2172,
"skips": []
},
{
"path": "services/traffic-flow-map-hops.test.js",
"durationMs": 1661,
"skips": []
},
{
"path": "services/traffic-flow-purge.test.js",
"durationMs": 2451,
"skips": []
},
{
"path": "services/geoip-download.test.js",
"durationMs": 667,
"skips": []
},
{
"path": "services/traffic-flow-geoip.test.js",
"durationMs": 1930,
"skips": []
},
{
"path": "services/traffic-flow-facts.test.js",
"durationMs": 964,
"skips": []
},
{
"path": "services/traffic-flow-facts-filter.test.js",
"durationMs": 1362,
"skips": []
},
{
"path": "services/traffic-flow-facts-rebuild.test.js",
"durationMs": 1833,
"skips": []
},
{
"path": "services/statistics-aggregate.test.js",
"durationMs": 1915,
"skips": []
}
]
},
"dedicatedIpv6": {
"database": "mm_accept_144_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-ipv6-pipeline.test.js",
"durationMs": 7879,
"skips": []
}
]
},
"dedicatedMonthly": {
"database": "mm_accept_149_1792139159368",
"passed": true,
"cases": [
{
"path": "services/traffic-flow-month.test.js",
"durationMs": 3800,
"skips": []
}
]
},
"note": "The two explicitly guarded SQL cases skipped in primary were then executed without skips in separate owned databases. This profile predates only the runtime-counter route fix."
},
"live": {
"sourceCommit": "db81cf0f",
"passed": true,
"checks": [
{
"name": "native-baselines-and-own-database-prepared",
"routers": 2
},
{
"name": "real-mikrotik-connected-1",
"address": "198.18.254.2"
},
{
"name": "real-mikrotik-connected-2",
"address": "198.18.254.3"
},
{
"name": "first-router-preserved-after-second",
"peerCount": 3
},
{
"name": "repeat-connect-without-duplicate"
},
{
"name": "synthetic-ipfix-through-real-wireguard-to-postgresql",
"packetsReceived": 4,
"lastExporterIp": "198.18.254.200"
},
{
"name": "agent-restart-preserves-tunnels-and-ipfix"
},
{
"name": "backend-real-restart-reconciled",
"peerCount": 4
},
{
"name": "ipfix-after-backend-restart"
},
{
"name": "persisted-exporter-summary",
"exporters": [
{
"server_id": 1,
"buckets": 44,
"bytes": "18451"
},
{
"server_id": 2,
"buckets": 27,
"bytes": "10281"
},
{
"server_id": 3,
"buckets": 1,
"bytes": "432075"
}
]
},
{
"name": "router-baseline-restored-2"
},
{
"name": "router-baseline-restored-1"
}
],
"nativeCleanup": true
},
"cleanup": {
"nativeAndHostCleanup": true,
"ownedDatabaseRemoved": true,
"exitCode": 0
},
"finalVerification": {
"passed": true,
"services": [
{
"name": "mmapp-test-backend",
"image": "mmapp-test/backend:ux-certificates258",
"imageId": "sha256:984684709e619972bae8657208e3150d12f1c4f2b5642fecf5286a5d87e9fe71",
"running": true,
"health": "healthy",
"imageUnchanged": true
},
{
"name": "mmapp-test-frontend",
"image": "mmapp-test/frontend:ux-accessibility261",
"imageId": "sha256:eec4410f1109a46e864d695cfb4311fdb9a224247549c8a9e78e44c6c0e455cb",
"running": true,
"health": null,
"imageUnchanged": true
},
{
"name": "mmapp-test-portal",
"image": "git.shx.one/denozord/auth-portal@sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
"imageId": "sha256:2bdf42400a1b35caa43303b9d01078b054b2a9041eba7019c1fffde71b7e4489",
"running": true,
"health": "healthy",
"imageUnchanged": true
},
{
"name": "mmapp-test-postgres",
"image": "postgres:18-alpine",
"imageId": "sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873",
"running": true,
"health": "healthy",
"imageUnchanged": true
}
],
"temporaryContainersRemaining": 0,
"hostOwnedWireguardRemaining": false,
"privateFixturesRemoved": true,
"mainFlowSettings": {
"enabled": false,
"collector_ip": "10.255.254.1",
"prefix": "10.255.254.0/24",
"flow_listen_port": 4739,
"wg_listen_port": 51821,
"peerCount": 0
},
"ownedDatabasesAndRolesRemaining": 0,
"routers": [
{
"id": "1",
"host": "194.226.121.150",
"reachable": true,
"ownedWireguardInterfaces": 0,
"ownedFlowTargets": 0,
"ownedCollectorRoutes": 0,
"trafficFlowEnabled": "false"
},
{
"id": "2",
"host": "104.165.244.7",
"reachable": true,
"ownedWireguardInterfaces": 0,
"ownedFlowTargets": 0,
"ownedCollectorRoutes": 0,
"trafficFlowEnabled": "false"
}
]
},
"correction": {
"commit": "0abdac82",
"problem": "Settings API returned stale persisted counters despite successful worker ingestion.",
"change": "Read packet count, last exporter, error and packet time from collector runtime counters.",
"regressionProof": "Initial live run persisted flows but timed out waiting for the settings packet counter after agent restart. Final run observed packetsReceived=4 and exporter 198.18.254.200, then passed both actual container restarts and continued reception.",
"localValidation": [
"Backend/contracts build",
"8 netflow-settings test scripts",
"ESLint for modified route"
]
},
"earlierAttempts": [
{
"stage": "SQL runner",
"result": "Harness FLOW_OPTIONAL truthiness corrected; primary profile rerun passed."
},
{
"stage": "Live runner bootstrap",
"result": "Production image lacks development pino-pretty; harness disabled pretty logging; resources cleaned."
},
{
"stage": "Live counter verification",
"result": "Product stale-counter defect detected and fixed; both routers and host restored before final full live rerun.",
"failure": {
"phase": "exercise",
"name": "Error",
"message": "Timed out: IPFIX continues during agent restart"
}
}
],
"nativeExportEvidence": "Own DB recorded server_id 1 and 2 through their distinct WG source addresses: 44 buckets/18451 bytes and 27 buckets/10281 bytes. Server 3 is the separate synthetic Linux IPFIX exporter (1 bucket/432075 bytes). These are exported flow-byte totals, not captured UDP wire bytes.",
"limits": [
"One-time installer was not applied to the primary stand deployment; its compose file is named compose.yaml. Installer integration with wg-quick/systemd and cold host reboot remain unverified on this stand.",
"No production deployment, registry publication, or primary frontend/backend replacement was performed.",
"Docker images and source/log artifacts are retained under /opt/mmapp-test/artifacts/netflow-agent-db81cf0f-20261016; temporary containers, owned DBs/roles and private fixture files were removed.",
"This is a functional acceptance run, not a load or long-duration stability test."
]
}
@@ -0,0 +1,78 @@
{
"date": "2026-10-15",
"scope": "Production diagnosis using a read-only application account and read-only SSH/system/PostgreSQL inspection. No production deployment or configuration changes.",
"productionVersion": "2.0.0",
"map": {
"cause": "PostgreSQL polledAt text with explicit offset is rejected by the ISO-only map validator; the whole topology snapshot is discarded.",
"before": {
"serverValidationErrors": 4,
"field": "polledAt",
"error": "invalid_format"
},
"localFix": {
"preservesExplicitTimeZone": true,
"rejectsMissingZoneAndInvalidCalendarDate": true,
"exactPrivateResponseAccepted": {
"servers": 4,
"tunnels": 25,
"probes": 0
}
},
"checks": {
"unitPassed": 6,
"e2ePassed": 13,
"typescript": "passed",
"scopedEslint": "passed"
},
"deployed": false
},
"traffic": {
"confirmed": "REST history/list/detail load once per scope/retry; live SSE keeps updating separately. History can become stale while live rate is current.",
"calculationNotYetFullyAccepted": true,
"rawResponsesPrivate": true
},
"performance": {
"host": {
"vcpu": 1,
"memoryMiB": 1973,
"swapMiB": 0,
"loadAverage": [
4.62,
4.87,
5.09
],
"rootUsedPercent": 46
},
"sample": {
"postgresCpuPercent": 66.51,
"postgresMemory": "642.6MiB",
"backendCpuPercent": 3.26,
"cpuIdlePercent": 0,
"ioWaitPercent": [
34,
29
]
},
"schedulerRuns": {
"estimatedLiveRows": 1064090,
"tableSize": "853 MB",
"indexes": "primary key and (job_key, started_at), no finished_at index",
"observedActiveQuery": "DELETE FROM scheduler_runs WHERE finished_at < $1",
"wait": "IO/AioIoCompletion",
"retention": "30 days after every recorded scheduler job",
"explainEquivalentSelect": "Gather / Parallel Seq Scan"
},
"tcpConnectMsByAnonymousServerId": {
"1": 36,
"2": 3,
"3": 50,
"4": 6
},
"limits": [
"TCP connect success does not prove TLS/REST authentication or rule out previous failures.",
"The native HTTP client timeout also includes time queued in maxSockets=2; actual queue delay has not been measured.",
"Database temp-byte and deadlock counters are cumulative with an unknown reset epoch; not attributed to this release.",
"No proof that database pressure alone caused every REST timeout."
]
}
}
@@ -0,0 +1,40 @@
{
"date": "2026-10-15",
"authorization": "Only the retention index and load verification in production",
"productionAction": {
"startedAt": "2026-10-15T14:55:45Z",
"statement": "CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs (finished_at)",
"result": "success",
"valid": true,
"ready": true,
"verifiedAt": "2026-10-15T15:01:30Z",
"size": "23 MB",
"indexScans": 697,
"planBefore": "Parallel Seq Scan",
"planAfter": "Bitmap Heap Scan / Bitmap Index Scan idx_scheduler_runs_finished_at",
"restarts": false,
"manualDataDeletion": false,
"retentionPeriodChanged": false,
"applicationDeployed": false
},
"observations": [
{ "period": "before", "postgresCpuPercent": 66.51, "hostIdlePercent": [0, 0], "hostIoWaitPercent": [29, 34], "ioPressureSomeAvg10Percent": 63.67 },
{ "period": "2026-10-15T14:56Z", "postgresCpuPercent": 0.16, "hostIdlePercent": [74, 94], "hostIoWaitPercent": [0, 5], "ioPressureSomeAvg10Percent": 1.70 },
{ "period": "2026-10-15T15:00Z", "postgresCpuPercent": 0.13, "hostIdlePercent": [94, 97], "hostIoWaitPercent": [0, 1], "ioPressureSomeAvg10Percent": 0.48 }
],
"checks": {
"actualMigrationTest": "passed on separate test Debian; bundled from committed TypeScript source",
"temporaryRows": 20000,
"migrationIdempotence": "passed",
"selectivePlanAndResultPreservation": "passed",
"temporaryFixtureRolledBack": true,
"testBootstrapExecuted": false,
"backendBuild": "passed",
"scopedEslint": "passed"
},
"limitations": [
"Several-minute observations under ordinary workload, not a capacity benchmark",
"Not proof that every RouterOS timeout or interface stall has been resolved",
"Map fix remains local; traffic history refresh and numerical validation remain separate work"
]
}
@@ -0,0 +1,44 @@
{
"date": "2026-10-16",
"scope": "Local backend fix plus read-only verification against production JH; no application deployment or RouterOS configuration writes",
"causes": [
"RouterOS list omitted disabled and check-gateway; strict catalog parser returned 503",
"The page loaded full 12k-25k routing tables; an observed 8-second read approached the 10-second timeout"
],
"nativeObservations": {
"disabledGet": { "ret": "false" },
"unsetCheckGatewayGet": [],
"configurationWrites": 0
},
"fix": {
"routeList": "POST /ip/route/print with .query=[comment]; includes all ownership markers and unsupported commented rows",
"gatewayList": "GET /ip/route with .proplist=.id,gateway,active; retains all routes",
"missingProperties": "Read disabled/check-gateway via /ip/route/get only for owned rows with absent fields",
"maximumPropertyConcurrency": 2,
"emptySuccessfulCheckGet": "none",
"malformedAndFailedReads": "rejected, no successful empty snapshot or cache update"
},
"readOnlyVerification": [
{ "serverId": 2, "fullRows": 12441, "fullReadMs": 2394, "commentedRows": 12, "commentedReadMs": 500, "parsedOwnedRows": 4, "effectivePropertyReads": 7, "combinedReadAndParseMs": 1026 },
{ "serverId": 3, "fullRows": 24864, "fullReadMs": 7986, "commentedRows": 13, "commentedReadMs": 1697, "parsedOwnedRows": 3, "effectivePropertyReads": 4, "combinedReadAndParseMs": 3225 },
{ "serverId": 4, "fullRows": 12451, "fullReadMs": 2106, "commentedRows": 7, "commentedReadMs": 488, "parsedOwnedRows": 5, "effectivePropertyReads": 3, "combinedReadAndParseMs": 643 }
],
"checks": {
"recursiveBackendProfile": "4 scripts passed, including new effective-property tests and HTTP failure/no-cache coverage",
"routerOsHttpClient": "11 tests passed, including native query array serialization",
"recursivePageE2E": "15 passed",
"backendBuild": "passed",
"scopedEslint": "passed",
"gitDiffCheck": "passed"
},
"limitations": [
"Read-only native checks, not acceptance of a deployed HTTP/UI fix",
"One-off timings; gateway inventory remains a separate request and is not included in combinedReadAndParseMs",
"No broader timeout/connection-pool redesign or changes to native route application rules",
"Existing cache ID replacement behavior remains separate from this fix"
],
"sources": [
"https://manual.mikrotik.com/docs/developer-guides/rest-api/",
"https://manual.mikrotik.com/docs/cli-reference/ip/route/"
]
}
@@ -0,0 +1,40 @@
{
"date": "2026-10-16",
"authorization": "Publish prepared Filters/map/recursive fixes to main; after successful CI update production backend and frontend only",
"version": "2.0.2",
"revision": "e6a2e4009d8af9e0e2f98c645791981c2b6d6ca8",
"ci": { "url": "https://git.shx.one/denozord/MikrotikManager/actions/runs/2468", "allSevenJobs": "success" },
"images": {
"backend": "sha256:1a02d517b16d9d475b478e7fae1a2749bad88a156f0e49183e8cb57e2d1a7f10",
"frontend": "sha256:fbd0c3743613a60d18dc6a13120cf1d755f89bc574c490e363d235301a267765",
"ociVersionAndRevision": "verified before restart"
},
"deployment": {
"backendStartedAt": "2026-10-16T00:41:33+07:00",
"frontendStartedAt": "2026-10-16T00:42:11+07:00",
"backendHealth": "healthy",
"frontendHealth": "healthy",
"postgresContainerAndStartedAtUnchanged": true,
"composeFileUnchanged": true,
"composeSha256": "e675c00d1cbdf6558f9cad601c2674007d899d1b47c92acd0d866ff51bb61bd31",
"routerOsConfigurationWriteActionsByAgent": 0,
"permissionsChanged": false
},
"httpAcceptance": [
{ "serverId": 2, "routesHttp": 200, "routes": 4, "routeReadMs": 1048, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 9, "gatewayReadMs": 1167 },
{ "serverId": 3, "routesHttp": 200, "routes": 3, "routeReadMs": 2575, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 7, "gatewayReadMs": 3800 },
{ "serverId": 4, "routesHttp": 200, "routes": 5, "routeReadMs": 736, "live": true, "stale": false, "gatewaysHttp": 200, "gateways": 8, "gatewayReadMs": 993 }
],
"uiAcceptance": {
"recursiveRoutes": "All three JH selected; 4/3/5 route endpoints displayed without the previous error; grouped rows may contain multiple endpoints",
"filters": "Selected server 2: 10 rendered rules, zero missing-route warnings, recursive /32 addresses visible",
"map": "4 devices (3 JH/1 EN), expanded graphical legend, 25 GRE loaded / 3 rendered links",
"pointerSelection": { "selected": true, "computedOutlineStyle": "none", "keyboardRingOpacity": "0" },
"editingAndDeletion": "not executed; read-only account"
},
"limitations": [
"Read-only DNS still rejected by mm:network:write; unresolved FQDN links are not confirmed",
"One-off API timings, not a capacity benchmark or proof that every network timeout is eliminated"
],
"privateScreenshotsOutsideGit": ["tmp/prod272-recursive.jpg", "tmp/prod272-filters.jpg", "tmp/prod272-map-overview.jpg"]
}
+123
View File
@@ -0,0 +1,123 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { logStreamSchema, type LogEntry, type LogPage, type LogStream, type LogSummary } from "@mmapp/contracts"
import { getLogs, getLogSummary } from "@/shared/api/logs"
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
export function mergeLogEntries(previous: LogEntry[], incoming: LogEntry[], limit = 5000) {
return [...new Map([...previous, ...incoming].map(row => [row.id, row])).values()].sort((a, b) => b.effectiveAt.localeCompare(a.effectiveAt) || (BigInt(a.id) < BigInt(b.id) ? 1 : -1)).slice(0, limit)
}
const message = (error: unknown) => error instanceof Error ? error.message : String(error)
export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) {
const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null)
const [summary, setSummary] = useState<LogSummary | null>(null), [stream, setStream] = useState<LogStream | null>(null)
const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false)
const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null)
const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0)
const pending = useRef<LogEntry[]>([]), pausedRef = useRef(paused), initialCursor = useRef<string | null>(null)
const lifetime = useRef<AbortController | null>(null), moreBusy = useRef(false)
useEffect(() => { pausedRef.current = paused }, [paused])
const flush = useCallback(() => { const buffered = pending.current; setEntries(prev => mergeLogEntries(prev, buffered).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs)); pending.current = []; setPendingCount(0) }, [windowMs])
useEffect(() => {
if (!paused && pending.current.length) flush()
}, [paused, flush])
useEffect(() => {
if (!enabled) return
const ac = new AbortController(); lifetime.current = ac
queueMicrotask(() => { if (!ac.signal.aborted) { setLoading(true); setError(null) } })
const currentQuery = () => { const params = new URLSearchParams(query); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString()); return params }
void getLogs(base, currentQuery(), ac.signal).then(data => {
if (ac.signal.aborted) return
setEntries(data.entries); setPage(data); initialCursor.current = data.watermark
}).catch(e => { if (!ac.signal.aborted) setError(message(e)) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
const readSummary = () => { void getLogSummary(base, currentQuery(), ac.signal).then(data => { if (!ac.signal.aborted) { setSummary(data); setSummaryError(null) } }).catch(e => { if (!ac.signal.aborted) setSummaryError(message(e)) }) }
readSummary()
const timer = setInterval(readSummary, 10_000)
return () => { ac.abort(); clearInterval(timer); initialCursor.current = null }
}, [base, query, enabled, revision, windowMs])
const ready = page !== null && !loading && !error
useEffect(() => {
if (!enabled || !live || !ready || !initialCursor.current) return
const ac = new AbortController()
let cursor = initialCursor.current, retry = 1000
void (async () => {
while (!ac.signal.aborted) {
const connection = new AbortController(), abort = () => connection.abort()
ac.signal.addEventListener("abort", abort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const heartbeat = () => { clearTimeout(watchdog); watchdog = setTimeout(() => connection.abort(), 20_000) }
try {
heartbeat()
const params = new URLSearchParams(query); params.set("cursor", cursor); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString())
const res = await fetch(resolveApiUrl(base, "/api/logs/live?" + params), { headers: withAuthHeaders({ Accept: "text/event-stream" }), signal: connection.signal, credentials: "include" })
if (!res.ok || !res.body) {
const body = await res.json().catch(() => null)
throw Object.assign(new Error(body?.error ?? `Live недоступен (HTTP ${res.status})`), { status: res.status })
}
const reader = res.body.getReader(), decoder = new TextDecoder()
let buffer = ""
try {
while (!ac.signal.aborted) {
const { done, value } = await reader.read()
if (done) throw new Error("Поток завершён; переподключение…")
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""
if (buffer.length > 8 * 1024 * 1024) throw new Error("Слишком большой ответ live")
for (const block of blocks) {
const type = block.match(/^event:\s*(.+)$/m)?.[1]
const data = block.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n")
if (type === "reset") {
const reason = "Поток остановлен для обслуживания БД. После завершения обновите журнал, чтобы заново загрузить историю."
if (!ac.signal.aborted) setError(reason)
return
}
if (type === "error") throw new Error(JSON.parse(data).error)
if (type !== "batch") continue
const batch = logStreamSchema.parse(JSON.parse(data))
const expectedServer = new URLSearchParams(query).get("serverId")
if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор")
heartbeat(); retry = 1000; cursor = batch.cursor
if (ac.signal.aborted) return
setStream(batch); setStreamError(null)
if (batch.entries.length) {
if (pausedRef.current) {
pending.current = mergeLogEntries(pending.current, batch.entries)
setPendingCount(n => n + batch.entries.length)
} else setEntries(prev => mergeLogEntries(prev, batch.entries).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs))
} else if (!pausedRef.current && windowMs) {
setEntries(prev => { const firstExpired = prev.findIndex(row => Date.parse(row.effectiveAt) < Date.now() - windowMs); return firstExpired < 0 ? prev : prev.slice(0, firstExpired) })
}
}
}
} finally { await reader.cancel().catch(() => {}) }
} catch (e) {
if (ac.signal.aborted) return
setStreamError(message(e))
if (e && typeof e === "object" && "status" in e && [401, 403, 404].includes(Number(e.status))) return
} finally { clearTimeout(watchdog); connection.abort(); ac.signal.removeEventListener("abort", abort) }
await new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); ac.signal.removeEventListener("abort", done); resolve() }
const timer = setTimeout(done, retry); ac.signal.addEventListener("abort", done, { once: true }); if (ac.signal.aborted) done()
})
retry = Math.min(15_000, retry * 2)
}
})()
return () => ac.abort()
}, [base, query, enabled, live, ready, revision, windowMs])
const loadMore = useCallback(async () => {
if (!page?.nextCursor || moreBusy.current || !enabled) return
moreBusy.current = true; setFetchingMore(true)
const signal = lifetime.current?.signal
try {
const params = new URLSearchParams(query); params.set("cursor", page.nextCursor); params.set("watermark", page.watermark)
const data = await getLogs(base, params, signal)
if (signal?.aborted) return
setEntries(prev => mergeLogEntries(prev, data.entries)); setPage(data)
} catch (e) { if (!signal?.aborted) setError(message(e)) }
finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) }
}, [base, query, enabled, page])
const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, [])
return { entries, summary, stream, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush }
}
+1 -1
View File
@@ -232,7 +232,7 @@ export function permissionForPath(pathname: string): string | null {
return "mm:dashboard:read"
}
if (pathname.startsWith("/probes")) return "mm:servers:read"
if (pathname.startsWith("/terminal")) return "mm:servers:read"
if (pathname.startsWith("/terminal") || pathname.startsWith("/logs")) return "mm:servers:read"
if (pathname.startsWith("/servers")) return "mm:servers:read"
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
return "mm:filters:read"
+1
View File
@@ -42,6 +42,7 @@ export const navigationItems: NavigationItem[] = [
{ id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: BadgeCheckIcon, keywords: ["ssl","tls","cert","pki","x509"] },
{ id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: HardDriveIcon, keywords: ["backup","резервная копия"] },
// Инструменты
{ id: "logs", title: "Логи", group: "Инструменты", url: "/logs", icon: ActivityIcon, keywords: ["logs", "журнал", "события", "syslog", "ошибки"] },
{ id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: RouteIcon, keywords: ["routing","wan","failover","переключение"] },
{ id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: GitForkIcon, keywords: ["ospf","igp","neighbors","lsa"] },
{ id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: GitMergeIcon, keywords: ["bgp","ebgp","ibgp","сессии","peers"] },
+12
View File
@@ -2,6 +2,18 @@ import {test} from "node:test"
import assert from "node:assert/strict"
import {validateNetworkMapSnapshot} from "./network-map-response"
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
test("network map retains jump and exit nodes with PostgreSQL timestamps",()=>{
const timestamps=["2026-10-15 13:46:40.855+00","2026-10-15 20:46:40.855+07","2026-10-15T13:46:40.855Z",null]
const rows=timestamps.map((polledAt,index)=>({...server,id:index+1,type:index===0?"exit-node":"jump-host",polledAt}))
const snapshot=validateNetworkMapSnapshot(rows,{tunnels:[],failures:[]},{probes:[]})
assert.equal(snapshot.servers.length,4)
assert.equal(snapshot.servers[0].type,"exit-node")
assert.equal(Date.parse(snapshot.servers[0].polledAt!),Date.parse(snapshot.servers[1].polledAt!))
assert.equal(snapshot.servers[3].polledAt,null)
for(const polledAt of ["2026-10-15 13:46:40.855","2026-02-30 13:46:40+00","not-a-date",42]) {
assert.throws(()=>validateNetworkMapSnapshot([{...server,polledAt}],{tunnels:[],failures:[]},{probes:[]}))
}
})
test("network map retains configured GRE IPsec without key material",()=>{
const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]})
+7 -1
View File
@@ -2,12 +2,18 @@ import {z} from "zod"
import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response"
import {filtersGreResponseSchema} from "./filters-response"
import {greResponseSchema} from "./gre-response"
// Drizzle's string-mode timestamptz is returned as PostgreSQL text (+00),
// while test/other API adapters may already return ISO. Preserve the explicit
// offset and validate the resulting calendar date; never infer a missing zone.
const mapObservedTime=z.preprocess(value=>typeof value==="string"
?value.replace(/^(\d{4}-\d{2}-\d{2}) /,"$1T").replace(/([+-]\d{2})$/,"$1:00")
:value,z.string().datetime({offset:true}))
export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({
status:z.enum(["online","offline","degraded"]).nullable(),
asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(),
sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(),
cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(),
polledAt:z.string().datetime({offset:true}).nullable().optional(),
polledAt:mapObservedTime.nullable().optional(),
})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory))
export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number]
const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId)))
+10 -1
View File
@@ -114,7 +114,16 @@ export interface GeoipUpdateRunSnapshot {
errors: string[]
}
export interface RouterLogsRunSnapshot {
v: 1
job: "router_logs"
sampledAt: string
skipped?: boolean
errors: string[]
servers: { serverId: number; name: string; added: number; gap: boolean; error?: string }[]
}
export type SchedulerRunSnapshot =
| RouterLogsRunSnapshot
| TrafficRunSnapshot
| ResourcesRunSnapshot
| PingRunSnapshot
@@ -234,7 +243,7 @@ export function parseSchedulerRunSnapshot(raw: unknown): SchedulerRunSnapshot |
if (o.skipped !== undefined && typeof o.skipped !== "boolean") return null
if (o.fatalError !== undefined && typeof o.fatalError !== "string") return null
for (const field of ["errors", "eventErrors"]) if (o[field] !== undefined && (!Array.isArray(o[field]) || !(o[field] as unknown[]).every(error => typeof error === "string"))) return null
const arrays: Record<string, string> = { traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
const arrays: Record<string, string> = { router_logs: "servers", traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
if (Object.hasOwn(arrays, o.job)) {
if (!Array.isArray(o[arrays[o.job]])) return null
} else {
+3
View File
@@ -2,6 +2,7 @@
/** Последний ключ — движок оповещений (читает БД после коллекторов; см. описание `alert_engine`). */
export const SCHEDULER_JOB_KEYS = [
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -17,6 +18,7 @@ export const SCHEDULER_JOB_KEYS = [
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
export const SCHEDULER_JOB_LABELS: Record<string, string> = {
router_logs: "Логи MikroTik",
traffic: "Трафик",
servers_rest_ping: "Серверы: REST API",
uptime_resources: "Uptime: ресурсы",
@@ -32,6 +34,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
/** Кратко — для раскрытой карточки прогона и подсказок. */
export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
router_logs: "REST-сбор буфера memory в PostgreSQL. Параметры — в панели «Логи MikroTik» ниже; live ускоряет опрос выбранного устройства до 2 секунд.",
traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в PostgreSQL.",
servers_rest_ping:
"Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.",
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import { backendInternalUrl } from "./lib/backend-internal-url";
const nextConfig: NextConfig = {
distDir: process.env.MM_NEXT_DIST_DIR || ".next",
output: "standalone",
experimental: {
proxyClientMaxBodySize: "512mb",
+1
View File
@@ -7,6 +7,7 @@
"backend"
],
"scripts": {
"test:logs": "playwright test --config playwright.logs-context.config.ts",
"dev": "next dev",
"build": "npm run build -w @mmapp/contracts && next build",
"postinstall": "npm run build -w @mmapp/contracts",
+1
View File
@@ -1,4 +1,5 @@
export * from "./servers.js"
export * from "./logs.js"
export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod"
export const logLevels = ["critical", "error", "warning", "info", "debug", "unknown"] as const
export const logLevelSchema = z.enum(logLevels)
export type LogLevel = z.infer<typeof logLevelSchema>
const id = z.string().regex(/^\d+$/)
const instant = z.string().datetime({ offset: true })
export const logEntrySchema = z.object({
id, serverId: z.number().int(), serverName: z.string(), host: z.string(),
routerId: z.string(), rawTime: z.string(), eventAt: instant.nullable(), receivedAt: instant,
effectiveAt: instant, timeKind: z.enum(["router", "receipt"]), topics: z.array(z.string()),
level: logLevelSchema, message: z.string(), generation: z.number().int(),
})
export type LogEntry = z.infer<typeof logEntrySchema>
export const logFiltersSchema = z.object({
serverId: z.coerce.number().int().positive().optional(), from: instant.default(() => new Date(Date.now() - 900000).toISOString()), to: instant.optional(),
levels: z.string().max(100).default("").refine(v => !v || v.split(",").every(x => (logLevels as readonly string[]).includes(x))),
topics: z.string().max(500).default(""), q: z.string().max(500).default(""),
preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
}).refine(v => !v.from || !v.to || Date.parse(v.from) <= Date.parse(v.to), "Некорректный период")
export type LogFilters = z.infer<typeof logFiltersSchema>
export const logPageQuerySchema = logFiltersSchema.safeExtend({
cursor: z.string().max(200).optional(), watermark: id.optional(),
limit: z.coerce.number().int().min(1).max(200).default(200),
})
export const logStateSchema = z.object({ serverId: z.number().int(), serverName: z.string(), host: z.string(),
lastCollectedAt: instant.nullable(), lastError: z.string().nullable(), gapAt: instant.nullable(),
nextPollAt: instant.nullable(), failures: z.number().int(), generation: z.number().int() })
export const logPageSchema = z.object({ entries: z.array(logEntrySchema), nextCursor: z.string().nullable(), watermark: id })
export type LogPage = z.infer<typeof logPageSchema>
export const logSettingsSchema = z.object({ enabled: z.boolean(), intervalSec: z.number().int().min(5).max(3600), retentionDays: z.number().int().min(1).max(365) })
export type LogSettings = z.infer<typeof logSettingsSchema>
export const logSettingsPatchSchema = logSettingsSchema.partial().strict().refine(v => Object.keys(v).length > 0)
export const logSummarySchema = z.object({ total: z.number(), levels: z.record(logLevelSchema, z.number()),
buckets: z.array(z.object({ at: instant, critical: z.number(), error: z.number(), warning: z.number(), info: z.number(), debug: z.number(), unknown: z.number() })),
settings: logSettingsSchema, states: z.array(logStateSchema) })
export type LogSummary = z.infer<typeof logSummarySchema>
export const logStreamSchema = z.object({ entries: z.array(logEntrySchema), cursor: id, state: logStateSchema.nullable(), enabled: z.boolean() })
export type LogStream = z.infer<typeof logStreamSchema>
export type LogStreamReset = { reason: "database-maintenance" }
+20
View File
@@ -9,6 +9,23 @@ export const flowHostPeerSchema = z.object({
endpoint: z.string().optional(),
})
export const trafficFlowHostStatusSchema = z.object({
configured: z.boolean(),
reachable: z.boolean(),
interfaceUp: z.boolean(),
configMatches: z.boolean().nullable(),
publicKey: z.string(),
listenPort: z.number().int().nonnegative(),
addresses: z.array(z.string()),
peers: z.array(z.object({
publicKey: z.string(), allowedIps: z.array(z.string()),
lastHandshakeAt: z.number().int().nonnegative().nullable(),
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
})),
error: z.string().nullable(),
})
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
export const trafficFlowSettingsDtoSchema = z.object({
enabled: z.boolean(),
collectorIp: z.string().min(1),
@@ -77,6 +94,9 @@ export const trafficFlowOverlayResultSchema = z.object({
trafficFlow: z.boolean(),
steps: z.array(z.string()),
hostFiles: z.array(trafficFlowHostFileSchema),
hostStatus: trafficFlowHostStatusSchema.optional(),
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
hostError: z.string().optional(),
})
export const flowTalkerDtoSchema = z.object({
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/wireguard-context.spec.ts", "**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
testIgnore: ["**/logs-context.spec.ts", "**/wireguard-context.spec.ts", "**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false,
workers: 1,
retries: 0,
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base, testIgnore: [], testMatch: "logs-context.spec.ts",
use: { ...base.use, baseURL: "http://127.0.0.1:3101" },
webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local", MM_NEXT_DIST_DIR: "tmp/next-logs-test" } },
})
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base,
use: { ...base.use, baseURL: "http://127.0.0.1:3102" },
testIgnore: [],
testMatch: ["netflow-agent.spec.ts", "netflow-settings.spec.ts"],
webServer: {
...server,
command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3102",
url: "http://127.0.0.1:3102",
},
})
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
import { logEntrySchema, logPageSchema, logSettingsSchema, logSettingsPatchSchema, logSummarySchema } from "@mmapp/contracts"
import { requestBlob, requestJson } from "./http-client"
const timedSignal = (signal?: AbortSignal, ms = 30000) => AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(ms)])
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Ответ сервера журнала не подтверждён: некорректные данные")
return parsed.data
}
export async function getLogs(base: string, params: URLSearchParams, signal?: AbortSignal) {
const result = confirmed(logPageSchema, await requestJson(base, "/api/logs?" + params, { signal: timedSignal(signal) }))
const server = params.get("serverId")
if (server && result.entries.some(row => String(row.serverId) !== server)) throw new Error("Журнал относится к другому устройству")
return result
}
export async function getLogSummary(base: string, params: URLSearchParams, signal?: AbortSignal) {
return confirmed(logSummarySchema, await requestJson(base, "/api/logs/summary?" + params, { signal: timedSignal(signal) }))
}
export async function getLogContext(base: string, id: string, signal?: AbortSignal) {
return confirmed(z.object({ entry: logEntrySchema, before: z.array(logEntrySchema), after: z.array(logEntrySchema) }), await requestJson(base, `/api/logs/${encodeURIComponent(id)}/context`, { signal: timedSignal(signal) }))
}
export async function getLogSettings(base: string, signal?: AbortSignal) { return confirmed(logSettingsSchema, await requestJson(base, "/api/logs/settings", { signal: timedSignal(signal) })) }
export async function putLogSettings(base: string, patch: z.infer<typeof logSettingsPatchSchema>, signal?: AbortSignal) {
const input = logSettingsPatchSchema.parse(patch)
const result = confirmed(z.object({ ok: z.literal(true), settings: logSettingsSchema }), await requestJson(base, "/api/logs/settings", { method: "PUT", body: JSON.stringify(input), signal: timedSignal(signal) }))
if (Object.entries(input).some(([key, value]) => result.settings[key as keyof typeof result.settings] !== value)) throw new Error("Сервер подтвердил другие настройки; обновите данные")
return result.settings
}
export async function exportLogs(base: string, params: URLSearchParams, format: "csv" | "json", signal?: AbortSignal) {
const query = new URLSearchParams(params); query.set("format", format)
const res = await requestBlob(base, "/api/logs/export?" + query, { signal: timedSignal(signal, 60000) })
const blob = await res.blob(), url = URL.createObjectURL(blob), anchor = document.createElement("a")
anchor.href = url; anchor.download = `router-logs.${format}`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more