Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5efd0339fb | ||
|
|
e461dc21bd | ||
|
|
2852b11234 | ||
|
|
0abdac82de | ||
|
|
db81cf0f97 | ||
|
|
fcca92706b | ||
|
|
4881306f0b | ||
|
|
ef229ca9c4 | ||
|
|
25ae76dc3f | ||
|
|
552d5f831f | ||
|
|
e6a2e4009d | ||
|
|
eb24a08981 | ||
|
|
1c11a133f3 | ||
|
|
0f0d3aa060 | ||
|
|
042407f03e | ||
|
|
b2cf71fcc7 | ||
|
|
fda7f353ad |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
deploy/netflow-agent/*.sh text eol=lf
|
||||
deploy/netflow-agent/*.py text eol=lf
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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/
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||
export default function LogsPage() { return <LogsWorkspace /> }
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 : "Не удалось прочитать шлюзы" })
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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 : "Настройки хоста не применены" })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"')
|
||||
})
|
||||
@@ -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('"', '""') + '"'
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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></> }
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,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() }}>
|
||||
|
||||
@@ -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.
|
||||
@@ -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`. Автоматического отката после успешной
|
||||
проверки не предусмотрено: включение разрешено явно. Другие настройки не трогать.
|
||||
@@ -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 не включён.
|
||||
@@ -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, чтобы документирование приёмки само не запускало следующий релиз.
|
||||
@@ -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).
|
||||
@@ -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, не перезапускать маршрутизатор. Результат записать
|
||||
отдельно: отключение ещё не выполнялось и причинная связь не доказана.
|
||||
@@ -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.
|
||||
Активная физическая/логическая репликация не обнаружена; параметры БД не менялись.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 обновите статус хоста и нажмите «Применить настройки хоста»."
|
||||
@@ -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()
|
||||
@@ -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` и права создать временную схему; при недоступной БД завершится ошибкой,
|
||||
а не выдаст пропуск за успешную проверку.
|
||||
@@ -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
@@ -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 не расширены.
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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:[]})
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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), замер задержки без полного опроса.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from "./servers.js"
|
||||
export * from "./logs.js"
|
||||
export * from "./alerts.js"
|
||||
export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } },
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user