fix(evolution): проверить серверный каталог и завершить локальную приёмку

This commit is contained in:
Denozordec committed 2026-10-03 14:12:06 +07:00
1 parent d272ef57f0
commit 2b6d3ab2e0
8 files changed
+169 -129

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && 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",
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
"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:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
@@ -1,15 +1,17 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {SignJWT} from "jose"
import {createEvoBgpCatalogRoutes} from "../routes/evobgp-catalog.js"
import {createEvoBgpSettingsRoutes} from "../routes/evobgp-settings.js"
process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test"
const {default:authPlugin,requireAuth}=await import("./auth.js")
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
let writes=0,probes=0
let writes=0,probes=0,catalogs=0
await app.register(createEvoBgpSettingsRoutes({read:async()=>({baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}),write:async()=>{writes++;return {baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}},probe:async()=>{probes++;return {items:[]}}}),{prefix:"/api"})
await app.register(createEvoBgpCatalogRoutes({read:async()=>({baseUrl:"https://evo.example.test",enabled:true,apiKey:"fixture-only"}),fetchCatalog:async()=>{catalogs++;return {modules:{items:[]},domains:{items:[]},ip_ranges:{items:[]},asns:{items:[]},communities:{items:[]}}},serversByCommunity:async()=>new Map()}),{prefix:"/api"})
for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
for(const [method,url] of [["GET","/api/evobgp/settings"],["PUT","/api/evobgp/settings"],["POST","/api/evobgp/test"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:{enabled:false}}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403);assert.equal(response.body.includes("fixture-only"),false)}
for(const [method,url] of [["GET","/api/evobgp/settings"],["PUT","/api/evobgp/settings"],["POST","/api/evobgp/test"],["POST","/api/evobgp/catalog"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:{enabled:false}}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403);assert.equal(response.body.includes("fixture-only"),false)}
}
assert.equal(writes,1);assert.equal(probes,1);assert.equal((await app.inject({method:"GET",url:"/api/evobgp/settings"})).statusCode,401)
await app.close();console.log("Evolution JWT gate: actual settings/test handlers require settings admin, no key echo")
assert.equal(writes,1);assert.equal(probes,1);assert.equal(catalogs,1);assert.equal((await app.inject({method:"POST",url:"/api/evobgp/catalog"})).statusCode,401);assert.equal((await app.inject({method:"GET",url:"/api/evobgp/settings"})).statusCode,401)
await app.close();console.log("Evolution JWT gate: actual settings/test/catalog handlers require settings admin, no key echo")
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {createEvoBgpCatalogRoutes} from "./evobgp-catalog.js"
const empty={modules:{items:[]},domains:{items:[]},ip_ranges:{items:[]},asns:{items:[]},communities:{items:[]}}
let settings={enabled:true,baseUrl:"EVO.EXAMPLE.test/",apiKey:"Bearer fixture-secret"},reads=0,fetches=0,serverReads=0,failFetch=false,failLocal=false
let catalog:unknown=empty
const app=Fastify();await app.register(createEvoBgpCatalogRoutes({read:async()=>{reads++;return {...settings}},fetchCatalog:async(root,key)=>{fetches++;assert.equal(root,"https://evo.example.test");assert.equal(key,"fixture-secret");if(failFetch)throw Error("upstream unavailable");return catalog},serversByCommunity:async()=>{serverReads++;if(failLocal)throw Error("local associations unavailable");return new Map([["64512:1",3]])},now:()=>new Date("2026-10-03T00:00:00Z")}),{prefix:"/api"})
const load=()=>app.inject({method:"POST",url:"/api/evobgp/catalog",payload:{baseUrl:"https://untrusted.test",apiKey:"untrusted"}})
let response=await load();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{fetchedAt:"2026-10-03T00:00:00.000Z",modules:[],domains:[],ipRanges:[],asns:[],communities:[]});assert.equal(reads,1);assert.equal(fetches,1)
catalog={...empty,modules:{items:[{id:"m",name:"Example module",type:"custom"}]},domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},asns:{items:[{module_id:"m",entry:{id:"a",asn:64512,community_id:"c"}},{module_id:"m",entry:{id:"z",asn:64513,community_id:"z",prefix_count:0}}]},communities:{items:[{id:"c",community:"64512:1",title:"Example community"},{id:"z",community:"64512:2"},{id:"empty",community:"64512:3"}]}}
response=await load();assert.equal(response.statusCode,200);const mapped=response.json();assert.equal(mapped.asns[0].prefixes,null);assert.equal(mapped.asns[1].prefixes,0);assert.equal(mapped.communities[0].prefixCount,null);assert.equal(mapped.communities[0].serverCount,3);assert.equal(mapped.communities[1].prefixCount,0);assert.equal(mapped.communities[2].prefixCount,0);assert.equal(mapped.domains[0].purpose,"Example module");assert.equal(response.body.includes("fixture-secret"),false)
const serverBefore=serverReads;for(const malformed of [{},null,{...empty,domains:undefined},{...empty,asns:{items:[{module_id:"m",entry:{id:"a",asn:1,prefix_count:1.2}}]}}]){catalog=malformed;response=await load();assert.equal(response.statusCode,502);assert.match(response.json().error,/не подтверждён/);assert.equal(response.json().domains,undefined)}assert.equal(serverReads,serverBefore)
settings={...settings,enabled:false};const before=fetches;response=await load();assert.equal(response.statusCode,400);assert.equal(fetches,before)
settings={...settings,enabled:true,apiKey:""};response=await load();assert.equal(response.statusCode,400);assert.equal(fetches,before)
settings={...settings,apiKey:"fixture-secret"};catalog=empty;failFetch=true;response=await load();assert.equal(response.statusCode,502);assert.equal(fetches,before+1);assert.equal(response.json().domains,undefined)
failFetch=false;failLocal=true;response=await load();assert.equal(response.statusCode,502);assert.equal(response.json().domains,undefined)
await app.close();console.log("Evolution catalog HTTP: one settings snapshot, DB credentials only, known empty/nullable counts, malformed/upstream/local errors without fabricated success")
+126
View File
@@ -0,0 +1,126 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp"
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js"
interface Dependencies {
read: () => Promise<{enabled:boolean;baseUrl:string;apiKey:string}>
fetchCatalog: (root:string,key:string) => Promise<unknown>
serversByCommunity: () => Promise<Map<string,number>>
now?: () => Date
}
export function createEvoBgpCatalogRoutes(deps:Dependencies):FastifyPluginAsyncZod {
return async app => {
app.post("/evobgp/catalog", async (_req, reply) => {
const row = await deps.read()
if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
}
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = normalizeApiKey(row.apiKey)
const cred = root && apiKey ? {root,apiKey} : null
if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
}
try {
const catalog = parseEvoCatalogRaw(await deps.fetchCatalog(cred.root, cred.apiKey))
const modules = catalog.modules.items
const modMap = new Map(modules.map((m) => [m.id, m]))
const commItems = catalog.communities.items
const commLabel = new Map<string, string>()
for (const c of commItems) {
const label = (c.community && c.title)
? `${c.title} (${c.community})`
: (c.title ?? c.community ?? c.id)
commLabel.set(c.id, label)
}
const now = deps.now?.() ?? new Date()
const fmtTime = now.toLocaleString("ru-RU")
const fetchedAt = now.toISOString()
const domains = catalog.domains.items.map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
domain: rowItem.entry.fqdn,
resolvedIp: "—",
asn: "—",
purpose: mod?.name ?? "—",
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const ipRanges = catalog.ip_ranges.items.map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
cidr: rowItem.entry.prefix,
asn: "—",
country: "—",
purpose: mod?.name ?? "—",
filter: commLabel.get(cid) ?? cid,
updated: fmtTime,
enabled: true,
}
})
const asns = catalog.asns.items.map((rowItem) => {
const cid = rowItem.entry.community_id
const e = rowItem.entry
const name = (e.asn_name ?? "").trim()
const prefixes = e.prefix_count ?? null
let updated = fmtTime
const rawResolved = (e.asn_resolved_at ?? "").trim()
if (rawResolved) {
const d = new Date(rawResolved)
if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU")
}
return {
id: `${rowItem.module_id}:${e.id}`,
asn: `AS${e.asn}`,
org: name || "—",
country: "—",
prefixes,
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated,
enabled: true,
}
})
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
const serversByCommunityStr = await deps.serversByCommunity()
const communities = commItems.map((c) => {
const valueStr = (c.community ?? "").trim()
return {
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0,
action: "permit" as const,
enabled: true,
}
})
return reply.send({
fetchedAt,
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
domains,
ipRanges,
asns,
communities,
})
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
}
}
+6 -123
View File
@@ -1,7 +1,6 @@
import { eq, sql } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp"
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js"
import { createEvoBgpCatalogRoutes } from "./evobgp-catalog.js"
import { createEvoBgpSettingsRoutes } from "./evobgp-settings.js"
import { db } from "../db/index.js"
import { evobgpSettings, filterRules } from "../db/schema.js"
@@ -71,14 +70,6 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
}
}
async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
const row = await ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
return { root, apiKey }
}
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
await app.register(createEvoBgpSettingsRoutes({
read: ensureEvobgpRow,
@@ -95,119 +86,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
},
}))
/**
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
*/
app.post("/evobgp/catalog", async (_req, reply) => {
const row = await ensureEvobgpRow()
if (!row.enabled) {
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
}
const cred = await credentialsFromDb()
if (!cred) {
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
}
try {
const catalog = parseEvoCatalogRaw(await fetchEvoJson<unknown>(cred.root, "/v1/router-lists/catalog", cred.apiKey))
const modules = catalog.modules.items
const modMap = new Map(modules.map((m) => [m.id, m]))
const commItems = catalog.communities.items
const commLabel = new Map<string, string>()
for (const c of commItems) {
const label = (c.community && c.title)
? `${c.title} (${c.community})`
: (c.title ?? c.community ?? c.id)
commLabel.set(c.id, label)
}
const fmtTime = new Date().toLocaleString("ru-RU")
const fetchedAt = new Date().toISOString()
const domains = catalog.domains.items.map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
domain: rowItem.entry.fqdn,
resolvedIp: "—",
asn: "—",
purpose: mod?.name ?? "—",
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
enabled: true,
}
})
const ipRanges = catalog.ip_ranges.items.map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
cidr: rowItem.entry.prefix,
asn: "—",
country: "—",
purpose: mod?.name ?? "—",
filter: commLabel.get(cid) ?? cid,
updated: fmtTime,
enabled: true,
}
})
const asns = catalog.asns.items.map((rowItem) => {
const cid = rowItem.entry.community_id
const e = rowItem.entry
const name = (e.asn_name ?? "").trim()
const prefixes = e.prefix_count ?? null
let updated = fmtTime
const rawResolved = (e.asn_resolved_at ?? "").trim()
if (rawResolved) {
const d = new Date(rawResolved)
if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU")
}
return {
id: `${rowItem.module_id}:${e.id}`,
asn: `AS${e.asn}`,
org: name || "—",
country: "—",
prefixes,
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated,
enabled: true,
}
})
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
const serversByCommunityStr = await distinctServersByFilterCommunityValue()
const communities = commItems.map((c) => {
const valueStr = (c.community ?? "").trim()
return {
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0,
action: "permit" as const,
enabled: true,
}
})
return reply.send({
fetchedAt,
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
domains,
ipRanges,
asns,
communities,
})
} catch (e) {
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
return reply.status(502).send({ error: msg })
}
})
await app.register(createEvoBgpCatalogRoutes({
read: ensureEvobgpRow,
fetchCatalog: (root,key) => fetchEvoJson<unknown>(root,"/v1/router-lists/catalog",key),
serversByCommunity: distinctServersByFilterCommunityValue,
}))
}
export default evobgpRoutes
+2
View File
@@ -282,3 +282,5 @@ REST запрос → ожидание → подтверждённый output /
Завершён 11Б.3.5.4.1: отдельная форма Evolution dirty/rights/lock/clear confirmation/source; 17 основных, 8 source и 3 responsive E2E, typecheck/lint/build прошли. 6 снимков, 320/ПК/clear просмотрены. Общие Settings и live consumer mocks ещё не приняты. Далее 11Б.3.5.4.2 — consumers/sidebar unknown/disabled/no fallback.
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.4.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.3.5.5 — серверная и локальная приёмка Evolution (2026-10-03)
- Catalog handler вынесен в DI plugin, production route использует те же функции. Enabled/URL/key читаются одним снимком настроек (раньше двумя GET БД), credentials поступают только из сохранённых данных, request body не переопределяет URL/key. Mapping сохраняет null/0 и local server associations; timestamps берутся из одного now.
- HTTP injection проверил known empty, реальный mapping modules/domain/ASN/community, unknown/zero/known sums, malformed upstream (502, без empty receipt и без чтения local associations), disabled/missing key (400, upstream не вызывается), upstream и local-association failures. JWT реального auth plugin и реальных catalog/settings/test handlers: admin 200, остальные 403 до upstream, без токена 401, ключ не возвращается.
- Backend Evolution parser/HTTP/settings/JWT, backend build и lint прошли; prefer-const в новом тесте исправлен. Frontend 17 form/provider, 8 source, 18 consumer и 3 responsive сценария, count E2E, typecheck/production build и снимки описаны в предыдущих блоках; повторять без нового frontend изменения не требуется.
- Evolution принят в границах локальных HTTP-фикстур и DI. Реальный upstream format/version и nullable counts, PG selective writes/associations, Portal, доступность внешнего EvoBGP и фактическое применение сетевых списков требуют тестового окружения. Read-only catalog POST может продолжать работу backend после browser abort; автоматического повтора mutations нет.
- Следующий блок 11Б.3.6.1 — общие Settings: убрать фиктивное сохранение/demo API keys/MFA и параметры без реализации; оставить реальные source/интеграции/переходы, отдельная приёмка system DB операций затем. Alerts/Releases/probe scheduling/приёмка 11Б остаются впереди.
## Блок 11Б.3.5.4.2 — live потребители каталога (2026-10-03)
- Domains/IP ranges/ASN/communities используют mock rows только в mock mode. В live unknown/disabled/no access не становится демонстрационными строками или подтверждённым нулём. Общий status разделяет unread/disabled/error/last snapshot/freshness; retry может перечитать настройки отключённой интеграции. Подтверждённый empty catalogue и search no-results имеют отдельные подсказки.
+2
View File
@@ -214,3 +214,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
| 11Б.3.5.4.1 Evolution | [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | Статус и свежесть → поля → сохранить / проверить → подтверждённый результат; удаление ключа → подтверждение → результат |
Завершён 11Б.3.5.4.2: live catalog consumers/sidebar/Filters без demo fallback, unknown/disabled/last snapshot и current details; 25 E2E, typecheck/профильный lint/build прошли. 3 community screenshots, 320/ПК просмотрены. Sidebar sync-effect baseline error и Filters unused warnings описаны в журнале. Далее 11Б.3.5.5 — HTTP/JWT catalog и приёмка Evolution.
Завершён 11Б.3.5.5: Evolution catalog DI HTTP/JWT, один settings snapshot, backend test/build/lint прошли. Evolution принят локально в границах предыдущих form/provider/source/consumer/adaptive проверок; реальный upstream/PG/Portal/RouterOS не принят. Далее 11Б.3.6.1 — достоверные общие Settings без фиктивных сохранений/API keys/MFA; затем system DB, Alerts/Releases/probes.