feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
// Persists ambiguous manual queue retry per source; never stores payload or credentials.
const pending=new Set<string>(),listeners=new Set<()=>void>()
const key=(source:string)=>"mm:alerts-outbox-pending:"+source
export function outboxPending(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
export function subscribeOutboxPending(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener)}}
export function setOutboxPending(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* Memory guard survives section navigation. */}for(const listener of listeners)listener()}
+6
View File
@@ -0,0 +1,6 @@
// Shared between Telegram settings and rule preview; never stores credentials.
const pending=new Set<string>(),listeners=new Set<()=>void>()
const key=(source:string)=>"mm:alerts-telegram-pending:"+source
export function telegramPending(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
export function subscribeTelegramPending(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener)}}
export function setTelegramPending(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* Memory guard survives section navigation. */}for(const listener of listeners)listener()}
+36
View File
@@ -0,0 +1,36 @@
/**
* API mode feature-flag.
*
* Set NEXT_PUBLIC_API_MODE=live in .env.local to switch from static mock data
* to real RouterOS REST API calls.
*
* Default: "mock"
*/
export const API_MODE = (
typeof process !== "undefined"
? (process.env.NEXT_PUBLIC_API_MODE ?? "mock")
: "mock"
) as "mock" | "live"
export const IS_MOCK = API_MODE !== "live"
/**
* Wrap an async data-fetch so it transparently returns mock data in "mock" mode
* or calls the live resolver in "live" mode.
*
* Example:
* export const fetchServers = apiOr(
* () => servers, // mock
* async (cfg) => apiClient(cfg).getAll() // live
* )
*/
export function apiOr<T, Args extends unknown[]>(
mock: (...args: Args) => T | Promise<T>,
live: (...args: Args) => T | Promise<T>,
): (...args: Args) => Promise<T> {
return async (...args: Args): Promise<T> => {
if (IS_MOCK) return mock(...args)
return live(...args)
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* RouterOS 7.x REST API client
*
* RouterOS 7.1+ exposes a REST API at https://<router>/rest/
* All responses are JSON; authentication is HTTP Basic or Bearer token.
*
* Usage (browser / server-component):
* const client = new RouterOsApiClient({ host: "10.0.0.1", port: 443, ... })
* const ifaces = await client.getInterfaces()
*/
export interface ApiClientConfig {
host: string // "10.0.0.1"
port: number // 443 (HTTPS) or 80 (HTTP)
tls: boolean // use HTTPS
username: string
password: string
tlsVerify: boolean // verify TLS certificate (false for self-signed)
timeout: number // ms, default 8000
}
// ─── RouterOS REST response shapes ───────────────────────────────────────────
export interface RosSystemIdentity {
name: string
}
export interface RosSystemResource {
uptime: string
version: string
"build-time": string
"free-memory": string
"total-memory": string
"cpu": string
"cpu-count": string
"cpu-load": string
"board-name": string
"platform": string
"architecture-name": string
"bad-blocks": string
}
export interface RosInterface {
".id": string
name: string
type: string
mtu: string
"actual-mtu": string
"mac-address": string
running: string
disabled: string
comment?: string
}
export interface RosBgpSession {
".id": string
name: string
"remote.address": string
"remote.as": string
state: string
"uptime": string
"prefix-count": string
disabled: string
}
export interface RosWireGuardInterface {
".id": string
name: string
"listen-port": string
mtu: string
"public-key": string
running: string
disabled: string
comment?: string
}
export interface RosWireGuardPeer {
".id": string
interface: string
"public-key": string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address": string
"last-handshake"?: string
"rx": string
"tx": string
disabled: string
comment?: string
}
// ─── Error class ─────────────────────────────────────────────────────────────
export class RouterOsApiError extends Error {
constructor(
public readonly status: number,
public readonly detail: string,
message: string,
) {
super(message)
this.name = "RouterOsApiError"
}
}
// ─── Client class ─────────────────────────────────────────────────────────────
export class RouterOsApiClient {
private readonly baseUrl: string
private readonly headers: Record<string, string>
private readonly timeout: number
constructor(private readonly cfg: ApiClientConfig) {
const scheme = cfg.tls ? "https" : "http"
this.baseUrl = `${scheme}://${cfg.host}:${cfg.port}/rest`
const creds = btoa(`${cfg.username}:${cfg.password}`)
this.headers = {
"Authorization": `Basic ${creds}`,
"Content-Type": "application/json",
}
this.timeout = cfg.timeout ?? 8000
}
// ── low-level fetch ──────────────────────────────────────────────────────
private async request<T>(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeout)
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: this.headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
clearTimeout(timer)
if (!res.ok) {
let detail = ""
try { detail = (await res.json())?.message ?? "" } catch { /* ignore */ }
throw new RouterOsApiError(res.status, detail, `RouterOS API ${method} ${path} → ${res.status}: ${detail}`)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
} catch (e) {
clearTimeout(timer)
throw e
}
}
async get<T>(path: string): Promise<T> {
return this.request<T>("GET", path)
}
async post<T>(path: string, body: unknown): Promise<T> {
return this.request<T>("POST", path, body)
}
/** RouterOS uses PUT for updates on a specific item by /.id */
async put<T>(path: string, id: string, body: unknown): Promise<T> {
return this.request<T>("PUT", `${path}/${encodeURIComponent(id)}`, body)
}
async patch<T>(path: string, id: string, body: unknown): Promise<T> {
return this.request<T>("PATCH", `${path}/${encodeURIComponent(id)}`, body)
}
async delete(path: string, id: string): Promise<void> {
return this.request<void>("DELETE", `${path}/${encodeURIComponent(id)}`)
}
// ── high-level helpers ───────────────────────────────────────────────────
async getSystemIdentity(): Promise<RosSystemIdentity> {
return this.get<RosSystemIdentity>("/system/identity")
}
async getSystemResource(): Promise<RosSystemResource> {
return this.get<RosSystemResource>("/system/resource")
}
async getInterfaces(): Promise<RosInterface[]> {
return this.get<RosInterface[]>("/interface")
}
async getBgpSessions(): Promise<RosBgpSession[]> {
return this.get<RosBgpSession[]>("/routing/bgp/session")
}
async getWireGuardInterfaces(): Promise<RosWireGuardInterface[]> {
return this.get<RosWireGuardInterface[]>("/interface/wireguard")
}
async getWireGuardPeers(): Promise<RosWireGuardPeer[]> {
return this.get<RosWireGuardPeer[]>("/interface/wireguard/peers")
}
/** Add a WireGuard interface */
async addWireGuardInterface(params: {
name: string
listenPort: number
mtu?: number
comment?: string
}): Promise<RosWireGuardInterface> {
return this.post<RosWireGuardInterface>("/interface/wireguard", {
name: params.name,
"listen-port": String(params.listenPort),
mtu: String(params.mtu ?? 1420),
comment: params.comment ?? "",
})
}
/** Add a WireGuard peer */
async addWireGuardPeer(params: {
iface: string
publicKey: string
allowedAddresses: string[]
endpoint?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
}): Promise<RosWireGuardPeer> {
return this.post<RosWireGuardPeer>("/interface/wireguard/peers", {
interface: params.iface,
"public-key": params.publicKey,
"allowed-address": params.allowedAddresses.join(","),
...(params.endpoint ? { "endpoint-address": params.endpoint } : {}),
...(params.endpointPort ? { "endpoint-port": String(params.endpointPort) } : {}),
...(params.persistentKeepalive ? { "persistent-keepalive": String(params.persistentKeepalive) } : {}),
comment: params.comment ?? "",
})
}
/** Ping test from the router — uses /tool/ping */
async ping(address: string, count = 4): Promise<{ "avg-rtt": string; "packet-loss": string }[]> {
return this.post("/tool/ping", { address, count: String(count) })
}
}
// ─── Factory helper ───────────────────────────────────────────────────────────
export function createClient(overrides: Partial<ApiClientConfig> & { host: string }): RouterOsApiClient {
return new RouterOsApiClient({
port: 443,
tls: true,
username: "admin",
password: "",
tlsVerify: false,
timeout: 8000,
...overrides,
})
}
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import {authPortalBaseUrl} from './app-switcher-config'
import {ensureAuthConfig} from './auth'
import {test} from 'node:test'
test('runtime Portal links override the build address',async()=>{
const previous=process.env.NEXT_PUBLIC_AUTH_PORTAL_URL,originalFetch=globalThis.fetch
try{
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL='https://build.example/'
assert.equal(authPortalBaseUrl(),'https://build.example')
globalThis.fetch=async()=>new Response(JSON.stringify({required:true,portal_url:'http://localhost:5175/'}),{headers:{'content-type':'application/json'}})
await ensureAuthConfig()
assert.equal(authPortalBaseUrl(),'http://localhost:5175','header links and switcher fetch share runtime Portal')
}finally{
globalThis.fetch=originalFetch
if(previous===undefined)delete process.env.NEXT_PUBLIC_AUTH_PORTAL_URL;else process.env.NEXT_PUBLIC_AUTH_PORTAL_URL=previous
}
console.log('app switcher: runtime Portal overrides build URL; fallback and trailing slash passed')
})
+163
View File
@@ -0,0 +1,163 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from "lucide-react"
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
import {getAuthConfigSync} from "@/lib/auth"
export const CURRENT_APP_ID = "mm"
export type AppSwitcherIconName =
| "server"
| "cloud"
| "globe"
| "dashboard"
| "chart"
export type AppSwitcherEntry = {
id: string
name: string
subtitle?: string
url: string
icon: AppSwitcherIconName
shortcut?: string
enabled?: boolean
}
export type AppSwitcherConfig = {
menuLabel: string
apps: AppSwitcherEntry[]
}
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
function localAppUrl(): string {
if (typeof window !== "undefined") return window.location.origin
return process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "") || "http://localhost:3000"
}
export function localMikrotikEntry(): AppSwitcherEntry {
return {
id: CURRENT_APP_ID,
name: "MikrotikManager",
subtitle: formatAppVersionLabel(getAppVersion()),
url: localAppUrl(),
icon: "dashboard",
enabled: true,
}
}
/** Offline fallback when auth-portal is unreachable. */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: "Приложения",
apps: [
localMikrotikEntry(),
{
id: "cfdm",
name: "CF Domain Manager",
subtitle: "Управление доменами",
url: "https://cfdm.shnt.top",
icon: "cloud",
},
{
id: "vps",
name: "VPS Tracker",
subtitle: "Учёт виртуальных серверов",
url: "https://vps.shnt.top",
icon: "server",
},
{
id: "bgp",
name: "EvoBGP",
subtitle: "BGP маршрутизация",
url: "https://bgp.shnt.top",
icon: "globe",
},
{
id: "fw",
name: "EvoFirewall",
subtitle: "Firewall controller",
url: "https://fw.shnt.top",
icon: "server",
},
{
id: "dns",
name: "Technitium DNS",
subtitle: "DNS-сервер",
url: "https://dns.shnt.top",
icon: "globe",
},
],
}
const ICON_NAMES = new Set<AppSwitcherIconName>([
"server",
"cloud",
"globe",
"dashboard",
"chart",
])
function asIcon(value: unknown): AppSwitcherIconName {
return ICON_NAMES.has(value as AppSwitcherIconName)
? (value as AppSwitcherIconName)
: "server"
}
export function parseAppSwitcherConfig(raw: unknown): AppSwitcherConfig | null {
if (!raw || typeof raw !== "object") return null
const data = raw as { menuLabel?: unknown; apps?: unknown }
if (!Array.isArray(data.apps) || data.apps.length === 0) return null
const apps: AppSwitcherEntry[] = []
for (const item of data.apps) {
if (!item || typeof item !== "object") continue
const row = item as Record<string, unknown>
if (typeof row.id !== "string" || typeof row.name !== "string" || typeof row.url !== "string") {
continue
}
if (row.enabled === false) continue
apps.push({
id: row.id,
name: row.name,
subtitle: typeof row.subtitle === "string" ? row.subtitle : undefined,
url: row.url,
icon: asIcon(row.icon),
shortcut: typeof row.shortcut === "string" ? row.shortcut : undefined,
enabled: true,
})
}
if (apps.length === 0) return null
return {
menuLabel: typeof data.menuLabel === "string" && data.menuLabel ? data.menuLabel : "Приложения",
apps,
}
}
export function mergeWithLocalApp(config: AppSwitcherConfig): AppSwitcherConfig {
const local = localMikrotikEntry()
const rest = config.apps.filter((app) => app.id !== CURRENT_APP_ID)
return { ...config, apps: [local, ...rest] }
}
export function authPortalBaseUrl(): string | null {
const raw = (getAuthConfigSync()?.portalUrl || process.env.NEXT_PUBLIC_AUTH_PORTAL_URL)?.trim()
if (!raw) return null
return raw.replace(/\/$/, "")
}
export function getCurrentApp(
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? localMikrotikEntry()
}
+17
View File
@@ -0,0 +1,17 @@
import {safeReleaseUrl} from "./release-metadata"
import packageJson from "@/package.json"
const packageVersion = packageJson.version
export function getAppVersion(): string {
return process.env.NEXT_PUBLIC_APP_VERSION?.trim() || `${packageVersion}-dev`
}
export function formatAppVersionLabel(version = getAppVersion()): string {
return `v${version}`
}
export function getReleaseUrl(): string | null {
const value = process.env.NEXT_PUBLIC_RELEASE_URL?.trim()
return safeReleaseUrl(value)
}
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForPath } from "./auth"
for (const permission of ["read", "admin", "approve", "pause"]) {
assert.equal(hasPermission(["mm:settings:admin"], `mm:mcp:${permission}`), true)
}
assert.equal(hasPermission(["mm:mcp:admin"], "mm:mcp:read"), true)
assert.equal(hasPermission(["mm:mcp:read"], "mm:mcp:admin"), false)
assert.equal(hasPermission(["mm:network:write"], "mm:mcp:read"), false)
assert.equal(permissionForPath("/mcp"), "mm:mcp:read")
assert.equal(permissionForPath("/mcp/connections"), "mm:mcp:read")
assert.notEqual(permissionForPath("/mcp-other"), "mm:mcp:read")
assert.equal(permissionForPath("/logs"), "mm:servers:read")
console.log("auth-mcp.test.ts: frontend MCP permissions and exact path boundaries pass")
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict'
import {safeAuthReturnPath,permittedAuthReturnPath} from './auth-return-path'
for(const input of [null,undefined,'','https://outside.invalid','//outside.invalid','/\\outside.invalid','/auth/callback?next=/servers','/api/servers','/access-denied','/a/../auth/callback','/%2f%2foutside','/%5coutside','/%61uth/callback','/%73ettings','/servers\n','/servers?'+ 'a'.repeat(2048)])assert.equal(safeAuthReturnPath(input),null,String(input))
assert.equal(safeAuthReturnPath('/servers?server=42&tab=details#private-token'),'/servers?server=42&tab=details')
assert.equal(safeAuthReturnPath('/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82'),'/traffic?period=24h&name=%D0%A2%D0%B5%D1%81%D1%82')
assert.equal(permittedAuthReturnPath('/servers?server=42',p=>p==='/servers','/dashboard'),'/servers?server=42')
assert.equal(permittedAuthReturnPath('/settings',p=>p==='/servers','/servers'),'/servers')
assert.equal(permittedAuthReturnPath('//outside.invalid',()=>true,'/servers'),'/servers')
console.log('auth return path: unsafe targets, normalized auth loops, query preservation and permission fallback passed')
+22
View File
@@ -0,0 +1,22 @@
/** Accept an application path, never an external URL or another auth handoff. */
export function safeAuthReturnPath(value: string | null | undefined): string | null {
if (!value || value.length > 2048 || !value.startsWith("/") || value.startsWith("//")) return null
if (/[\\\u0000-\u0020\u007f]/.test(value)) return null
try {
const url = new URL(value, "https://mmapp.invalid")
if (url.origin !== "https://mmapp.invalid" || /%/.test(url.pathname)) return null
if (/^\/(auth|api|access-denied)(\/|$)/.test(url.pathname)) return null
return url.pathname + url.search
} catch {
return null
}
}
export function permittedAuthReturnPath(
value: string | null | undefined,
allowed: (pathname: string) => boolean,
fallback: string,
): string {
const path = safeAuthReturnPath(value)
return path && allowed(new URL(path, "https://mmapp.invalid").pathname) ? path : fallback
}
+280
View File
@@ -0,0 +1,280 @@
/** Portal JWT storage + claims helpers for MikrotikManager. */
import { safeAuthReturnPath } from "./auth-return-path"
const TOKEN_KEY = "mmapp_token"
const HANDOFF_KEY = "mmapp_auth_401_handoff"
const HANDOFF_AT_KEY = "mmapp_portal_handoff_at"
const HANDOFF_COOLDOWN_MS = 12_000
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function envPortalUrl(): string {
return (
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175"
).replace(/\/$/, "")
}
function envAuthEnabled(): boolean {
const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase()
return v === "true" || v === "1"
}
/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch("/api/auth/config")
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || envAuthEnabled(),
portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""),
}
return runtimeConfig
}
} catch {
/* use env defaults */
}
runtimeConfig = {
required: envAuthEnabled(),
portalUrl: envPortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return envAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return envPortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === "undefined") return false
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_KEY, "1")
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
sessionStorage.removeItem(HANDOFF_KEY)
}
export function resetPortalHandoff(): void {
sessionStorage.removeItem(HANDOFF_KEY)
sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function redirectToPortalLogin(returnTo?: string): boolean {
if (isPortalHandoffCoolingDown()) {
clearToken()
return false
}
markPortalHandoff()
const callback = new URL(returnTo ?? `${window.location.origin}/auth/callback`)
const next = safeAuthReturnPath(callback.searchParams.get("next")) ??
safeAuthReturnPath(window.location.pathname + window.location.search)
if (next) callback.searchParams.set("next", next)
const url = new URL(authPortalUrl())
url.searchParams.set("return_to", callback.toString())
window.location.assign(url.toString())
return true
}
export function redirectToPortalLoginInteractive(): void {
clearToken()
resetPortalHandoff()
window.location.assign(authPortalUrl())
}
export function redirectToPortalLogout(): void {
clearToken()
resetPortalHandoff()
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith("#") ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get("access_token"),
expiresAt: params.get("expires_at"),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split(".")
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/"))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ""),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === "number" ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
resetPortalHandoff()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
if ((required.startsWith("mm:users:") || required.startsWith("mm:mcp:")) && granted.includes("mm:settings:admin")) {
return true
}
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes("mm")) return false
return hasPermission(claims.permissions, required)
}
export function permissionForPath(pathname: string): string | null {
if (pathname === "/mcp" || pathname.startsWith("/mcp/")) return "mm:mcp:read"
if (pathname === "/" || pathname.startsWith("/dashboard")) {
return "mm:dashboard:read"
}
if (pathname.startsWith("/probes")) 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"
}
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
if (pathname.startsWith("/traffic") || pathname.startsWith("/statistics")) {
return "mm:traffic:read"
}
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
if (pathname.startsWith("/backups")) return "mm:backups:read"
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
if (
pathname.startsWith("/network") ||
pathname.startsWith("/recursive-routes") ||
pathname.startsWith("/ospf") ||
pathname.startsWith("/route-optimizer")
) {
return "mm:network:read"
}
if (pathname.startsWith("/users")) return "mm:users:read"
if (pathname.startsWith("/settings")) return "mm:settings:admin"
return "mm:dashboard:read"
}
export function firstAllowedPath(): string {
const candidates = [
"/dashboard",
"/servers",
"/users",
"/filters",
"/uptime",
"/traffic",
"/statistics",
"/alerts",
"/backups",
"/settings",
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return "/access-denied"
}
+3
View File
@@ -0,0 +1,3 @@
export function backendInternalUrl(): string {
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
}
+51
View File
@@ -0,0 +1,51 @@
export const LOCAL_DEFAULT_BACKEND_URL = "http://localhost:8000"
export type ConfiguredBackendUrl =
| { kind: "local" }
| { kind: "fixed"; url: string }
| { kind: "same-origin" }
export function configuredBackendUrl(): ConfiguredBackendUrl {
const raw = process.env.NEXT_PUBLIC_BACKEND_URL
if (raw === undefined || raw === "local") return { kind: "local" }
if (raw === "" || raw === "same-origin") return { kind: "same-origin" }
return { kind: "fixed", url: raw.replace(/\/$/, "") }
}
export function defaultDataSourceMode(): "mock" | "live" {
if (!isMockDataSourceAvailable()) return "live"
return process.env.NEXT_PUBLIC_DEFAULT_DATA_SOURCE === "live" ? "live" : "mock"
}
export function isMockDataSourceAvailable(): boolean {
return process.env.NEXT_PUBLIC_ALLOW_MOCK_DATA !== "false"
}
export function isBackendUrlLocked(): boolean {
const cfg = configuredBackendUrl()
return cfg.kind === "same-origin" || cfg.kind === "fixed"
}
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1"
}
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
export function resolveStoredBackendUrl(stored: string | null): string {
const cfg = configuredBackendUrl()
if (cfg.kind === "fixed") return cfg.url
if (cfg.kind === "same-origin") {
if (typeof window !== "undefined") return window.location.origin
return ""
}
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
const trimmed = stored?.trim().replace(/\/$/, "")
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
}
return trimmed || LOCAL_DEFAULT_BACKEND_URL
}
+13
View File
@@ -0,0 +1,13 @@
import type { Backup } from "@/lib/data"
export function backupFileMissing(backup: Pick<Backup, "storage" | "localFileStatus">): boolean {
return backup.storage === "local" && backup.localFileStatus === "missing"
}
export function backupLocalFileDescription(backup: Pick<Backup, "storage" | "localFileStatus">): string | null {
if (backup.localFileStatus === "missing") return backup.storage === "both"
? "Локальная копия отсутствует. Файл будет запрошен из S3."
: "Локальный файл отсутствует."
if (backup.localFileStatus === "unknown") return "Не удалось проверить локальный файл."
return null
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { backupJobCheckpointSchema, createBackupJobCheckpoint, readBackupJobCheckpoint, backupJobStorageKey } from "./backup-job-checkpoint"
const id = "11111111-1111-4111-8111-111111111111"
test("checkpoint freezes a canonical request before acknowledgment", () => {
const ids = ["1", "2"], saved = createBackupJobCheckpoint({ serverIds: ids, notes: " manual " }, id); ids.push("3")
assert.deepEqual(saved.request.serverIds, ["1", "2"]); assert.equal(saved.request.notes, "manual"); assert.equal(saved.accepted, false); assert.deepEqual(readBackupJobCheckpoint(JSON.stringify(saved)), saved)
})
test("corrupt or foreign checkpoints cannot be restored", () => {
assert.throws(() => readBackupJobCheckpoint("bad")); assert.throws(() => createBackupJobCheckpoint({ serverIds: ["1", "1"] }, id))
const saved = createBackupJobCheckpoint({ serverIds: ["1"] }, id)
const job = { id: "other", status: "queued", serverIds: ["1"], total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
assert.equal(backupJobCheckpointSchema.safeParse({ ...saved, accepted: true, job }).success, false)
})
test("storage keys preserve backend scope and absent storage stays absent", () => {
assert.notEqual(backupJobStorageKey("one"), backupJobStorageKey("two")); assert.equal(readBackupJobCheckpoint(null), null)
})
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod"
import { backupJobResponseSchema, createBackupRequestSchema } from "@mmapp/contracts/backups"
import type { CreateBackupRequest } from "@mmapp/contracts/backups"
const requestSchema = createBackupRequestSchema.extend({ requestId: z.string().uuid() })
export const backupJobCheckpointSchema = z.object({ request: requestSchema, accepted: z.boolean(), job: backupJobResponseSchema.nullable() }).superRefine((value, ctx) => {
const job = value.job
if (job && (!value.accepted || job.id !== value.request.requestId || JSON.stringify(job.serverIds) !== JSON.stringify(value.request.serverIds.map(String)) || (job.notes ?? "") !== (value.request.notes ?? ""))) ctx.addIssue({ code: "custom", message: "Наблюдение относится к другой задаче" })
})
export type BackupJobCheckpoint = z.infer<typeof backupJobCheckpointSchema>
export function createBackupJobCheckpoint(request: CreateBackupRequest, requestId: string): BackupJobCheckpoint {
return backupJobCheckpointSchema.parse({ request: { ...request, requestId, serverIds: request.serverIds.map(String), notes: request.notes?.trim() || undefined }, accepted: false, job: null })
}
export function readBackupJobCheckpoint(value: string | null): BackupJobCheckpoint | null { return value ? backupJobCheckpointSchema.parse(JSON.parse(value)) : null }
export function backupJobStorageKey(backendUrl: string) { return "mmapp:backup-job:" + backendUrl }
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { pollBackupJob, backupJobOutcome, backupJobResultError, waitForBackupPoll } from "./backup-job-poll"
import type { BackupJobStatusResponse } from "@mmapp/contracts/backups"
const target = { serverIds: ["1"], notes: "manual" }
const baseJob: BackupJobStatusResponse = { id: "job", status: "queued", serverIds: ["1"], notes: "manual", total: 1, completed: 0, requestedAt: "2026-10-01T00:00:00Z", created: [], failures: [] }
const file = { id: "backup", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual" as const, storage: "local" as const }
test("serial queued/running reads complete only on terminal observation", async () => {
let reads = 0, inFlight = 0, maximum = 0
const job = await pollBackupJob("base", "job", target, { signal: new AbortController().signal, wait: async () => {}, readJob: async () => { inFlight++; maximum = Math.max(maximum, inFlight); await Promise.resolve(); const status = ++reads < 3 ? "running" : "done"; inFlight--; return { ...baseJob, status, ...(status === "done" ? { completed: 1, created: [file], finishedAt: "2026-10-01T00:01:00Z" } : {}) } } })
assert.equal(reads, 3); assert.equal(maximum, 1); assert.equal(backupJobOutcome(job).fullSuccess, true)
})
test("partial device and upload errors never become full success", () => {
for (const patch of [{ created: [], failures: [{ serverId: "1", error: "offline" }] }, { created: [{ ...file, uploadError: "S3 unavailable" }], failures: [] }]) {
assert.equal(backupJobOutcome({ ...baseJob, status: "done", completed: 1, ...patch }).fullSuccess, false)
}
})
test("foreign target is rejected before onJob", async () => {
let observations = 0
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, onJob: () => { observations++ }, readJob: async () => ({ ...baseJob, serverIds: ["2"] }) }), /не соответствует/)
assert.equal(observations, 0)
})
test("read failure or exhausted observation budget is not a failed job", async () => {
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, readJob: async () => { throw new Error("GET unavailable") } }), /GET unavailable/)
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: new AbortController().signal, attempts: 1, readJob: async () => baseJob }), /Время наблюдения/)
})
test("cancellation interrupts delay and suppresses late observation", async () => {
const abort = new AbortController(), wait = waitForBackupPoll(10_000, abort.signal); abort.abort(); await assert.rejects(wait)
const late = new AbortController(); let calls = 0
await assert.rejects(() => pollBackupJob("base", "job", target, { signal: late.signal, onJob: () => { calls++ }, readJob: async () => { late.abort(); return baseJob } }))
assert.equal(calls, 0)
})
test("saved terminal job reconstructs partial errors without a previous hook error", () => {
assert.equal(backupJobResultError(baseJob), null)
const success: BackupJobStatusResponse = { ...baseJob, status: "done", completed: 1, created: [file] }
assert.equal(backupJobResultError(success), null)
assert.match(backupJobResultError({ ...success, created: [{ ...file, uploadError: "S3 unavailable" }] }) ?? "", /config.rsc: S3 unavailable/)
assert.match(backupJobResultError({ ...success, created: [], failures: [{ serverId: "1", error: "offline" }] }) ?? "", /Создано 0 из 1.*1: offline/)
assert.match(backupJobResultError({ ...baseJob, status: "failed" }) ?? "", /Задача завершилась с ошибкой/)
})
+36
View File
@@ -0,0 +1,36 @@
import type { BackupJobStatusResponse, CreateBackupRequest } from "@mmapp/contracts/backups"
import { getBackupJob } from "@/shared/api/backups"
export function waitForBackupPoll(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return }
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) }
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms)
signal.addEventListener("abort", abort, { once: true })
})
}
export async function pollBackupJob(baseUrl: string, jobId: string, target: CreateBackupRequest, options: {
signal: AbortSignal; onJob?: (job: BackupJobStatusResponse) => void; readJob?: typeof getBackupJob; wait?: typeof waitForBackupPoll; attempts?: number; intervalMs?: number
}) {
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)])
const readJob = options.readJob ?? getBackupJob, wait = options.wait ?? waitForBackupPoll, attempts = options.attempts ?? 150
for (let index = 0; index < attempts; index++) {
signal.throwIfAborted()
const job = await readJob(baseUrl, jobId, { signal }); signal.throwIfAborted()
if (job.id !== jobId || JSON.stringify(job.serverIds) !== JSON.stringify(target.serverIds.map(String)) || (job.notes ?? "") !== (target.notes?.trim() ?? "")) throw new Error("Ответ задачи не соответствует выбранным серверам или заметке")
options.onJob?.(job)
if (job.status === "done" || job.status === "failed") return job
if (index + 1 < attempts) await wait(options.intervalMs ?? 1200, signal)
}
throw new Error("Время наблюдения истекло. Задача может продолжаться; повторите проверку той же задачи")
}
export function backupJobOutcome(job: BackupJobStatusResponse) {
const uploadFailures = job.created.filter(item => item.uploadError)
return { fullSuccess: job.status === "done" && job.created.length === job.total && !job.failures.length && !uploadFailures.length, created: job.created.length, deviceFailures: job.failures, uploadFailures }
}
export function backupJobResultError(job: BackupJobStatusResponse): string | null {
const outcome = backupJobOutcome(job)
if (!["done", "failed"].includes(job.status) || outcome.fullSuccess) return null
const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)]
return "Создано " + outcome.created + " из " + job.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой")
}
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { ApiClientError } from "@/shared/api/http-client"
import { backupRestoreStorageKey, readBackupRestoreCheckpoint, restoreWasNotApplied } from "./backup-restore-checkpoint"
test("restore checkpoint preserves target and rejects corrupt or incomplete state", () => {
const target = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router", requestedAt: "2026-10-01T00:00:00Z" }
assert.deepEqual(readBackupRestoreCheckpoint(JSON.stringify(target)), target); assert.equal(readBackupRestoreCheckpoint(null), null)
for (const payload of ["invalid JSON", JSON.stringify({}), JSON.stringify({ ...target, serverId: "1" }), JSON.stringify({ ...target, requestedAt: "bad" })]) assert.throws(() => readBackupRestoreCheckpoint(payload))
assert.notEqual(backupRestoreStorageKey("first"), backupRestoreStorageKey("second"))
})
test("unknown response is not proof that import was not applied", () => {
for (const error of [new Error("timeout"), new ApiClientError("old backend error", 400), new ApiClientError("lost after apply", 502, { outcome: "unknown" }), new ApiClientError("server error", 500)]) assert.equal(restoreWasNotApplied(error), false)
assert.equal(restoreWasNotApplied(new ApiClientError("prepare failed", 400, { outcome: "not-applied" })), true)
assert.equal(restoreWasNotApplied(new ApiClientError("forbidden", 403)), true)
})
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod"
import { ApiClientError } from "@/shared/api/http-client"
export const backupRestoreCheckpointSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1), requestedAt: z.string().datetime({ offset: true }) })
export type BackupRestoreCheckpoint = z.infer<typeof backupRestoreCheckpointSchema>
export function readBackupRestoreCheckpoint(value: string | null) { return value ? backupRestoreCheckpointSchema.parse(JSON.parse(value)) : null }
export function backupRestoreStorageKey(backendUrl: string) { return "mmapp:backup-restore:" + backendUrl }
export function restoreWasNotApplied(error: unknown): boolean {
if (!(error instanceof ApiClientError)) return false
if ([403, 404, 405].includes(error.status)) return true
return error.status === 400 && !!error.payload && typeof error.payload === "object" && "outcome" in error.payload && error.payload.outcome === "not-applied"
}
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { backupSchedulePayload, backupStoragePayload } from "./backup-settings"
const form = { enabled: false, frequency: "daily" as const, hour: "0", minute: "7", weekDay: 0, monthDay: 1, keepCount: "365", format: "rsc" as const }
test("schedule preserves zero, arbitrary minute and explicit all-enabled semantics", () => {
const result = backupSchedulePayload(form, ["1"], true)
assert.equal(result.hour, 0); assert.equal(result.minute, 7); assert.equal(result.keepCount, 365); assert.deepEqual(result.serverIds, [])
assert.deepEqual(backupSchedulePayload(form, ["1"], false).serverIds, ["1"])
})
test("blank, fractional, negative and out-of-range numbers are rejected without normalization", () => {
for (const hour of ["", "1.5", "-1", "24", "1e1"]) assert.throws(() => backupSchedulePayload({ ...form, hour }, ["1"], false))
assert.throws(() => backupSchedulePayload({ ...form, keepCount: "366" }, ["1"], false))
assert.throws(() => backupSchedulePayload(form, [], false))
})
test("unsupported binary schedule requires an explicit format choice", () => {
assert.throws(() => backupSchedulePayload({ ...form, enabled: true, format: "backup" }, ["1"], false))
assert.equal(backupSchedulePayload({ ...form, format: "backup" }, ["1"], false).format, "backup")
})
const storageForm = { provider: "s3" as const, s3Endpoint: "", s3Region: "", s3Bucket: "bucket", s3Prefix: "", s3AccessKeyId: "key", s3SecretAccessKey: "", s3ForcePathStyle: false, keepLocalCopy: false, showPassword: false }
test("S3 requires complete fields, endpoint protocol and a new or existing secret", () => {
assert.throws(() => backupStoragePayload(storageForm))
assert.equal(backupStoragePayload(storageForm, true).s3SecretAccessKey, undefined)
assert.equal(backupStoragePayload({ ...storageForm, s3SecretAccessKey: "new-test-secret" }).s3SecretAccessKey, "new-test-secret")
for (const patch of [{ s3Bucket: " " }, { s3AccessKeyId: " " }, { s3Endpoint: "invalid" }, { s3Endpoint: "ftp://example.test" }, { s3SecretAccessKey: " " }]) assert.throws(() => backupStoragePayload({ ...storageForm, ...patch }, true))
})
+27
View File
@@ -0,0 +1,27 @@
import { putBackupScheduleSettingsSchema, putBackupStorageSettingsSchema } from "@mmapp/contracts/backups"
import type { BackupScheduleForm, BackupStorageForm } from "@/components/backups/backups-settings"
function integer(value: number | string, label: string, min: number, max: number): number {
if (!/^[0-9]+$/.test(String(value))) throw new Error(label + ": введите целое число")
const number = Number(value)
if (!Number.isSafeInteger(number) || number < min || number > max) throw new Error(label + ": допустимо от " + min + " до " + max)
return number
}
export function backupSchedulePayload(form: BackupScheduleForm, serverIds: string[], allEnabled: boolean) {
if (!allEnabled && !serverIds.length) throw new Error("Выберите серверы или режим всех включённых устройств")
if (form.enabled && form.format !== "rsc") throw new Error("Плановый binary backup пока не поддерживается; выберите .rsc явно")
return putBackupScheduleSettingsSchema.parse({ ...form, hour: integer(form.hour, "Час", 0, 23), minute: integer(form.minute, "Минута", 0, 59), weekDay: integer(form.weekDay, "День недели", 0, 6), monthDay: integer(form.monthDay, "День месяца", 1, 28), keepCount: integer(form.keepCount, "Число копий", 1, 365), serverIds: allEnabled ? [] : serverIds })
}
export function backupStoragePayload(form: BackupStorageForm, secretConfigured = false) {
if (form.provider === "s3") {
if (!form.s3Bucket.trim() || !form.s3AccessKeyId.trim()) throw new Error("Для S3 заполните Bucket и Access key")
if (!form.s3SecretAccessKey.trim() && !secretConfigured) throw new Error("Для S3 требуется Secret key")
if (form.s3SecretAccessKey && !form.s3SecretAccessKey.trim()) throw new Error("Secret key не может состоять только из пробелов")
if (form.s3Endpoint) {
let endpoint: URL
try { endpoint = new URL(form.s3Endpoint) } catch { throw new Error("Endpoint должен быть полным URL http/https") }
if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("Endpoint должен использовать http/https")
}
}
return putBackupStorageSettingsSchema.parse({ provider: form.provider, s3Endpoint: form.s3Endpoint, s3Region: form.s3Region, s3Bucket: form.s3Bucket, s3Prefix: form.s3Prefix, s3AccessKeyId: form.s3AccessKeyId, s3SecretAccessKey: form.s3SecretAccessKey || undefined, s3ForcePathStyle: form.s3ForcePathStyle, keepLocalCopy: form.keepLocalCopy })
}
+11
View File
@@ -0,0 +1,11 @@
export function fmtBgpNum(n: number | null): string {
if (n === null) return "—"
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
return String(n)
}
export function sumBgpCounters(values: (number | null)[]): number | null {
return values.some(n => n === null) ? null : values.reduce<number>((sum, n) => sum + (n ?? 0), 0)
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod"
import { vxlanServersSchema } from "@/lib/vxlan-response"
const counter = z.number().int().nonnegative()
const autonomousSystem = z.number().int().min(1).max(4294967295).nullable()
export const bgpSessionSchema = z.object({
id: z.string().trim().min(1), serverId: z.number().int().positive(),
serverName: z.string(), serverSite: z.string(), serverCountry: z.string(),
name: z.string(), peerIp: z.string(), remoteAs: autonomousSystem, localAs: autonomousSystem,
localId: z.string(), remoteId: z.string(), state: z.string().trim().min(1), type: z.enum(["eBGP", "iBGP", "Unknown"]),
uptime: z.string().nullable(), holdTime: counter.nullable(), keepalive: counter.nullable(),
prefixesRx: counter.nullable(), prefixesTx: counter.nullable(), inputMessages: counter.nullable(), outputMessages: counter.nullable(),
afi: z.string().nullable().optional(),
capabilities: z.array(z.string()), lastError: z.string().nullable(),
})
export const bgpCatalogSchema = z.object({
sessions: z.array(bgpSessionSchema),
failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })),
}).refine(data => new Set(data.sessions.map(s => JSON.stringify([s.serverId, s.id]))).size === data.sessions.length
&& new Set(data.failures.map(f => f.serverId)).size === data.failures.length
&& !data.sessions.some(s => data.failures.some(f => f.serverId === s.serverId)))
export const bgpServersSchema = vxlanServersSchema
export type BackendBgpSession = z.infer<typeof bgpSessionSchema>
export type BgpFailure = z.infer<typeof bgpCatalogSchema>["failures"][number]
+38
View File
@@ -0,0 +1,38 @@
export type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" | "Unknown"
export type BgpType = "eBGP" | "iBGP" | "Unknown"
export type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
export interface BgpSessionRow {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number | null
localAs: number | null
routerId: string
description: string
state: BgpState
type: BgpType
afi: string
uptime: string | null
holdTime: number | null
keepalive: number | null
prefixesRx: number | null
prefixesTx: number | null
prefixesActive: number | null
inputMessages: number | null
outputMessages: number | null
capabilities: string[]
lastError: string | null
}
export const BGP_AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { createCertificateIssueCheckpoint, readCertificateIssueCheckpoint, certificateIssueStorageKey } from "./certificate-issue-checkpoint"
const id = "11111111-1111-4111-8111-111111111111"
const target = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
test("checkpoint persists ID and immutable target before acceptance", () => {
const value = createCertificateIssueCheckpoint(target, id)
target.domainNames.push("later.example")
assert.deepEqual(value.request.domainNames, ["test.example"])
assert.equal(value.accepted, false); assert.equal(value.job, null)
assert.deepEqual(readCertificateIssueCheckpoint(JSON.stringify(value)), value)
})
test("malformed and foreign restored observations are rejected", () => {
assert.throws(() => readCertificateIssueCheckpoint("bad"))
assert.throws(() => readCertificateIssueCheckpoint("{}"))
const value = createCertificateIssueCheckpoint({ ...target, domainNames: ["test.example"] }, id)
const job = { id, serverId: "2", certName: "cert", domainNames: ["test.example"], status: "done", step: "done", requestedAt: "2026-10-01T00:00:00Z" }
assert.throws(() => readCertificateIssueCheckpoint(JSON.stringify({ ...value, accepted: true, job })))
assert.throws(() => readCertificateIssueCheckpoint(JSON.stringify({ ...value, job: { ...job, serverId: "1" } })))
})
test("storage context is isolated per backend and absent checkpoint stays absent", () => {
assert.notEqual(certificateIssueStorageKey("https://one.example.test"), certificateIssueStorageKey("https://two.example.test"))
assert.equal(readCertificateIssueCheckpoint(null), null)
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod"
import { certificateIssueRequestSchema, certificateIssueJobSchema, type CertificateIssueRequest } from "@mmapp/contracts/certificates"
export const certificateIssueCheckpointSchema = z.object({
request: certificateIssueRequestSchema.extend({ requestId: z.string().uuid() }),
accepted: z.boolean(),
job: certificateIssueJobSchema.nullable(),
}).superRefine((value, ctx) => {
const job = value.job, request = value.request
if (job && (job.id !== request.requestId || job.serverId !== String(request.serverId) || job.certName !== request.certName || JSON.stringify(job.domainNames) !== JSON.stringify(request.domainNames))) ctx.addIssue({ code: "custom", message: "Сохранённая задача относится к другой цели" })
if (job && !value.accepted) ctx.addIssue({ code: "custom", message: "Наблюдение задачи не подтверждает принятие запроса" })
})
export type CertificateIssueCheckpoint = z.infer<typeof certificateIssueCheckpointSchema>
export function createCertificateIssueCheckpoint(request: CertificateIssueRequest, requestId: string) {
return certificateIssueCheckpointSchema.parse({ request: { ...request, requestId }, accepted: false, job: null })
}
export function readCertificateIssueCheckpoint(raw: string | null) {
return raw === null ? null : certificateIssueCheckpointSchema.parse(JSON.parse(raw))
}
export function certificateIssueStorageKey(backendUrl: string) { return "mmapp:certificate-issue:" + backendUrl }
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import type { CertificateIssueJob } from "@mmapp/contracts/certificates"
import { pollCertificateIssue, waitForCertificatePoll } from "./certificate-issue-poll"
const target = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
const queued: CertificateIssueJob = { id: "job-1", ...target, status: "queued", step: "queued", requestedAt: "2026-10-01T00:00:00Z" }
const signal = () => new AbortController().signal
test("queued/running are observations; only confirmed terminal job completes", async () => {
let reads = 0; const observed: string[] = []
const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), wait: async () => {}, readJob: async () => ++reads === 1 ? queued : reads === 2 ? { ...queued, status: "running", step: "import" } : { ...queued, status: "done", step: "done" }, onJob: job => observed.push(job.status) })
assert.equal(result.status, "done"); assert.deepEqual(observed, ["queued", "running", "done"])
})
test("failed job preserves server error without resubmission", async () => {
const result = await pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, status: "failed", step: "failed", error: "Import failed" }) })
assert.equal(result.status, "failed"); assert.equal(result.error, "Import failed")
})
test("foreign target or inconsistent done cannot invoke onJob or claim success", async () => {
for (const patch of [{ id: "other" }, { serverId: "2" }, { certName: "other" }, { domainNames: ["other.example"] }, { status: "done", step: "import" }]) {
let observations = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => ({ ...queued, ...patch } as CertificateIssueJob), onJob: () => { observations++ } }))
assert.equal(observations, 0)
}
})
test("read failure and exhausted observation budget do not become failed server jobs", async () => {
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), readJob: async () => { throw new Error("HTTP failure") } }), /HTTP failure/)
let reads = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: signal(), attempts: 2, wait: async () => {}, readJob: async () => { reads++; return queued } }), /может продолжаться/)
assert.equal(reads, 2)
})
test("caller cancellation interrupts delay and suppresses late observation", async () => {
const abort = new AbortController(); const waiting = waitForCertificatePoll(60_000, abort.signal); abort.abort(new Error("cancel")); await assert.rejects(() => waiting, /cancel/)
const late = new AbortController(); let observed = 0
await assert.rejects(() => pollCertificateIssue("", "job-1", target, { signal: late.signal, readJob: async () => { late.abort(new Error("source changed")); return { ...queued, status: "done", step: "done" } }, onJob: () => { observed++ } }), /source changed/)
assert.equal(observed, 0)
})
+36
View File
@@ -0,0 +1,36 @@
import type { CertificateIssueJob, CertificateIssueRequest } from "@mmapp/contracts/certificates"
import { getCertificateIssueJob } from "@/shared/api/certificates"
export function waitForCertificatePoll(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) { reject(signal.reason); return }
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason) }
const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve() }, ms)
signal.addEventListener("abort", abort, { once: true })
})
}
export async function pollCertificateIssue(baseUrl: string, jobId: string, target: CertificateIssueRequest, options: {
signal: AbortSignal
onJob?: (job: CertificateIssueJob) => void
readJob?: typeof getCertificateIssueJob
wait?: typeof waitForCertificatePoll
attempts?: number
intervalMs?: number
}) {
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(300_000)])
const readJob = options.readJob ?? getCertificateIssueJob
const wait = options.wait ?? waitForCertificatePoll
const attempts = options.attempts ?? 120
for (let index = 0; index < attempts; index++) {
signal.throwIfAborted()
const job = await readJob(baseUrl, jobId, { signal })
signal.throwIfAborted()
if (job.id !== jobId || job.serverId !== String(target.serverId) || job.certName !== target.certName || JSON.stringify(job.domainNames) !== JSON.stringify(target.domainNames)) throw new Error("Ответ задачи не соответствует выбранному серверу, сертификату или доменам")
if (job.status === "done" && job.step !== "done") throw new Error("Завершение задачи не подтверждено её шагом")
options.onJob?.(job)
if (job.status === "done" || job.status === "failed") return job
if (index + 1 < attempts) await wait(options.intervalMs ?? 2000, signal)
}
throw new Error("Время наблюдения истекло. Задача может продолжаться на сервере; повторите проверку этой задачи.")
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import type {CertificateObserved,CertificatesState} from "@mmapp/contracts/certificates"
import {mergeCertificateSnapshots} from "./certificate-snapshots"
import {readCertificatesState} from "../shared/api/certificates"
const cert:CertificateObserved={id:"1:*A",rosId:"*A",name:"test-cert",serverId:"1",commonName:"test.example",sans:[],issuedBy:"—",validFrom:null,validUntil:null,rawValidFrom:null,rawValidUntil:null,daysLeft:null,keySize:null,trusted:null,status:"unknown",usage:[]}
const node={serverId:"1",serverName:"Router",collectedAt:"2026-10-01T00:00:00Z",certificates:[cert],error:null}
const state:CertificatesState={collectedAt:node.collectedAt,servers:[node]}
test("partial failure retains confirmed device snapshot, latest error and timestamp independently",()=>{
const first=mergeCertificateSnapshots({},state),second=mergeCertificateSnapshots(first,{...state,servers:[{...node,collectedAt:"2026-10-01T01:00:00Z",certificates:[],error:"offline"}]})
assert.equal(second["1"].confirmed?.certificates[0].name,cert.name);assert.equal(second["1"].confirmed?.collectedAt,node.collectedAt);assert.equal(second["1"].latest.error,"offline");assert.equal(second["1"].latest.collectedAt,"2026-10-01T01:00:00Z");assert.equal(first["1"].latest.error,null)
})
test("initial failure stays unknown, confirmed empty clears old rows, removed devices are removed",()=>{
const failure=mergeCertificateSnapshots({}, {...state,servers:[{...node,certificates:[],error:"offline"}]});assert.equal(failure["1"].confirmed,null)
const good=mergeCertificateSnapshots({},state);const empty=mergeCertificateSnapshots(good,{...state,servers:[{...node,certificates:[]}]});assert.deepEqual(empty["1"].confirmed?.certificates,[]);assert.deepEqual(mergeCertificateSnapshots(good,{...state,servers:[]}),{})
})
test("device failures are independent and recovery replaces only its snapshot",()=>{
const first=mergeCertificateSnapshots({}, {...state,servers:[node,{...node,serverId:"2",certificates:[],error:"offline"}]});assert.equal(first["2"].confirmed,null)
const recovered=mergeCertificateSnapshots(first,{...state,servers:[{...node,certificates:[],error:"offline"},{...node,serverId:"2",certificates:[]}]});assert.equal(recovered["1"].confirmed?.certificates.length,1);assert.deepEqual(recovered["2"].confirmed?.certificates,[])
})
test("HTTP reader preserves unknown values and rejects malformed/duplicate/foreign envelopes",async()=>{
const original=globalThis.fetch;let payload:unknown=state,seen="",signal:AbortSignal|null|undefined
globalThis.fetch=async(input,init)=>{seen=String(input);signal=init?.signal;return new Response(JSON.stringify(payload),{status:200,headers:{"content-type":"application/json"}})}
try{
const controller=new AbortController();const result=await readCertificatesState("https://api.example.test",{signal:controller.signal});assert.equal(seen,"https://api.example.test/api/certificates/state");assert.equal(result.servers[0].certificates[0].daysLeft,null);assert.equal(result.servers[0].certificates[0].status,"unknown");controller.abort();assert.equal(signal?.aborted,true)
for(const bad of [{certificates:[]},{...state,servers:[node,node]},{...state,servers:[{...node,certificates:[{...cert,serverId:"2"}]}]},{...state,servers:[{...node,certificates:[{...cert,status:"valid"}]}]}]){payload=bad;await assert.rejects(()=>readCertificatesState("https://api.example.test"))}
}finally{globalThis.fetch=original}
})
test("HTTP error/empty ack do not become a successful empty catalog",async()=>{
const original=globalThis.fetch
try{
globalThis.fetch=async()=>new Response(JSON.stringify({error:"forbidden"}),{status:403});await assert.rejects(()=>readCertificatesState("https://api.example.test"),/forbidden/)
globalThis.fetch=async()=>new Response(null,{status:204});await assert.rejects(()=>readCertificatesState("https://api.example.test"))
}finally{globalThis.fetch=original}
})
+10
View File
@@ -0,0 +1,10 @@
import type {CertificatesState,CertificateServerState} from "@mmapp/contracts/certificates"
export type CertificateSnapshot = { latest: CertificateServerState; confirmed: CertificateServerState | null }
export type CertificateSnapshots = Record<string, CertificateSnapshot>
/** Replace the enabled-device inventory; retain each failed device's last confirmed snapshot. */
export function mergeCertificateSnapshots(previous:CertificateSnapshots,result:CertificatesState):CertificateSnapshots {
return Object.fromEntries(result.servers.map(node=>[node.serverId,{
latest:node,
confirmed:node.error===null?node:previous[node.serverId]?.confirmed??null,
}]))
}
+4
View File
@@ -0,0 +1,4 @@
import type { CertificateDto, CertificateObserved } from "@mmapp/contracts/certificates"
/** Общий тип для демонстрационных строк и подтверждённых полей RouterOS. */
export type CertificateView = CertificateDto | CertificateObserved
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { readCertificatesUrl, patchCertificatesUrl } from "./certificates-url-state"
test("invalid and duplicate context is rejected", () => {
for (const query of ["server=bad", "server=0", "server=-1", "server=1.5", "server=9007199254740992", "server=1&server=2", "status=bad", "status=valid&status=unknown", "q=a&q=b", "q=" + "x".repeat(201)]) assert.ok(readCertificatesUrl(new URLSearchParams(query)).error, query)
})
test("known context, mock identifier and empty defaults", () => {
assert.deepEqual(readCertificatesUrl(new URLSearchParams("server=2&status=unknown&q=test")), { server: "2", status: "unknown", search: "test", error: null })
assert.equal(readCertificatesUrl(new URLSearchParams("server=srv1")).error, null)
assert.deepEqual(readCertificatesUrl(new URLSearchParams()), { server: "all", status: "all", search: "", error: null })
})
test("patch preserves unrelated parameters and removes filters", () => {
assert.equal(patchCertificatesUrl(new URLSearchParams("server=1&status=valid&ref=map"), { server: null, status: null }), "/certificates?ref=map")
})
+16
View File
@@ -0,0 +1,16 @@
export type CertificateStatusFilter = "all" | "valid" | "expired" | "revoked" | "unknown"
const statuses = ["valid", "expired", "revoked", "unknown"]
export function readCertificatesUrl(params: URLSearchParams) {
const server = params.get("server"), status = params.get("status"), search = params.get("q") ?? ""
let error: string | null = null
if (["server", "status", "q"].some(key => params.getAll(key).length > 1)) error = "Параметры контекста сертификатов повторяются."
if (server !== null && !(/^[1-9][0-9]*$/.test(server) && Number.isSafeInteger(Number(server))) && !/^srv[1-9][0-9]*$/.test(server)) error = "В ссылке указан неверный сервер."
if (status !== null && !statuses.includes(status)) error = "В ссылке указан неизвестный статус."
if (search.length > 200) error = "Поиск в ссылке слишком длинный."
return { server: server ?? "all", status: (status ?? "all") as CertificateStatusFilter, search, error }
}
export function patchCertificatesUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) if (value === null || value === "") next.delete(key); else next.set(key, value)
return "/certificates" + (next.size ? "?" + next.toString() : "")
}
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod"
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
export interface ConfigRevisionDto {
id: string
serverId: string
section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre" | "ipsec"
source: ConfigRevisionSource
fingerprint: string
createdAt: string
note: string | null
itemCount: number
}
const revisionsSchema = z.object({ revisions: z.array(z.object({
id: z.string().min(1),
serverId: z.string().min(1),
section: z.enum(["filters", "recursive-routes", "firewall", "wireguard", "gre", "ipsec"]),
source: z.enum(["apply", "rollback", "observed", "copy"]),
fingerprint: z.string(),
createdAt: z.string().refine(value => Number.isFinite(Date.parse(value))),
note: z.string().nullable(),
itemCount: z.number().int().nonnegative(),
})) })
const sectionLabels: Record<ConfigRevisionDto["section"], string> = {
filters: "Filters", "recursive-routes": "рекурсивных маршрутов", firewall: "Firewall",
wireguard: "WireGuard", gre: "GRE", ipsec: "IPsec",
}
export function parseConfigRevisions(
payload: unknown,
serverId: string,
section: ConfigRevisionDto["section"],
): ConfigRevisionDto[] {
const result = revisionsSchema.safeParse(payload)
if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId || revision.section !== section)) {
throw new Error("Некорректный ответ истории " + sectionLabels[section])
}
return result.data.revisions
}
+7
View File
@@ -0,0 +1,7 @@
import assert from "node:assert/strict"
import {readContainersUrl,patchContainersUrl} from "./containers-url-state"
for(const q of ["server=bad","server=0","server=1.5","server=9007199254740992","server=1&server=2","status=bad","status=running&status=stopped","q=a&q=b","q="+"x".repeat(201)])assert.ok(readContainersUrl(new URLSearchParams(q)).error,q)
assert.deepEqual(readContainersUrl(new URLSearchParams("server=2&status=unknown&q=test")),{server:"2",status:"unknown",search:"test",error:null})
assert.deepEqual(readContainersUrl(new URLSearchParams()),{server:"all",status:"all",search:"",error:null})
assert.equal(patchContainersUrl(new URLSearchParams("server=1&status=running&ref=map"),{server:null,status:null}),"/containers?ref=map")
console.log("Containers URL: strict/duplicate context, empty defaults, unrelated parameters preserved")
+16
View File
@@ -0,0 +1,16 @@
export type ContainerStatusFilter = "all" | "running" | "stopped" | "error" | "unknown"
const statuses = ["running","stopped","error","unknown"]
export function readContainersUrl(params: URLSearchParams) {
const server = params.get("server"), status = params.get("status"), search = params.get("q") ?? ""
let error: string | null = null
if (["server","status","q"].some(key=>params.getAll(key).length>1)) error="Параметры контекста контейнеров повторяются."
if(server!==null && (!/^[1-9]\d*$/.test(server)||!Number.isSafeInteger(Number(server)))) error="В ссылке указан неверный сервер."
if(status!==null&&!statuses.includes(status))error="В ссылке указан неизвестный статус."
if(search.length>200)error="Поиск в ссылке слишком длинный."
return {server:server??"all",status:(status??"all") as ContainerStatusFilter,search,error}
}
export function patchContainersUrl(params:URLSearchParams,changes:Record<string,string|null>) {
const next=new URLSearchParams(params)
for(const [key,value] of Object.entries(changes))if(value===null||value==="")next.delete(key);else next.set(key,value)
return "/containers"+(next.size?"?"+next.toString():"")
}
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardBgp} from "./dashboard-bgp"
import {bgpSession} from "../tests/e2e/fixtures/bgp"
test("BGP global totals distinguish observed zero, unknown counters and unknown session state",()=>{
assert.deepEqual(parseDashboardBgp({sessions:[],failures:[]}),{prefixSum:0,establishedCount:0})
assert.deepEqual(parseDashboardBgp({sessions:[bgpSession()],failures:[]}),{prefixSum:321,establishedCount:1})
assert.deepEqual(parseDashboardBgp({sessions:[bgpSession(1,null)],failures:[]}),{prefixSum:null,establishedCount:1})
assert.deepEqual(parseDashboardBgp({sessions:[{...bgpSession(1,null),state:"Idle"}],failures:[]}),{prefixSum:0,establishedCount:0})
for(const state of ["Unknown","NotEstablished","custom-status"])assert.deepEqual(parseDashboardBgp({sessions:[{...bgpSession(),state}],failures:[]}),{prefixSum:null,establishedCount:null})
})
test("BGP rejects partial catalogs, malformed counters, duplicates and overflow before publication",()=>{
for(const raw of [{sessions:[]},{sessions:[{}],failures:[]},{sessions:[bgpSession(),bgpSession()],failures:[]},{sessions:[bgpSession(1,-1)],failures:[]},{sessions:[bgpSession(1,Number.MAX_SAFE_INTEGER),bgpSession(2,1)],failures:[]}])assert.throws(()=>parseDashboardBgp(raw))
assert.throws(()=>parseDashboardBgp({sessions:[bgpSession()],failures:[{serverId:2,serverName:"Недоступный узел",error:"timeout"}]}),/Недоступный узел: timeout/)
})
+16
View File
@@ -0,0 +1,16 @@
import {bgpCatalogSchema} from "@/lib/bgp/response"
export interface DashboardBgp {prefixSum:number|null;establishedCount:number|null}
/** A failed device cannot be counted as having no sessions in the global KPI. */
export function parseDashboardBgp(raw:unknown):DashboardBgp {
const catalog=bgpCatalogSchema.safeParse(raw)
if(!catalog.success)throw Error("Некорректный ответ каталога BGP")
if(catalog.data.failures.length)throw Error("Не опрошены устройства BGP: "+catalog.data.failures.map(f=>f.serverName+": "+f.error).join("; "))
let prefixSum=0,establishedCount=0,missingPrefix=false,unknownState=false
for(const session of catalog.data.sessions){
const state=session.state.toLowerCase()
if(state==="established"){establishedCount++;if(session.prefixesRx===null)missingPrefix=true;else prefixSum+=session.prefixesRx}
else if(!["active","idle","connect","opensent","openconfirm"].includes(state))unknownState=true
}
if(!Number.isSafeInteger(prefixSum))throw Error("Суммарный счётчик BGP превышает допустимый диапазон")
return {prefixSum:unknownState||missingPrefix?null:prefixSum,establishedCount:unknownState?null:establishedCount}
}
+367
View File
@@ -0,0 +1,367 @@
import {readProbeOutput} from "@/lib/probe-response"
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
import {
buildLiveOptimizerData,
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
type FiltersRulesetRow,
type OptimizerApiServer,
type RouteOptimizerSpeedProbe,
} from "@/lib/route-optimizer-data"
export interface InternetPathRoute {
wanId: string
jhId?: string
exitId?: string
reason: string
}
export interface InternetPathHop {
home: Server
wan: WanUplink
jumpHost: Server
exitNode: Server
wanJhMetrics: {
pingMs: number | null
dlMbps: number | null
ulMbps: number | null
fromMonitoring: boolean
}
jhExitMetrics: {
pingMs: number | null
dlMbps: number | null
ulMbps: number | null
fromMonitoring: boolean
}
}
export interface InternetPathViewModel {
homeRouter: Server
activeWanUplink: WanUplink | null
fallbackJumpHost: Server | null
fallbackExitNode: Server | null
primaryHop: InternetPathHop | null
currentHop: InternetPathHop | null
primaryPath: InternetPathRoute | null
currentPath: InternetPathRoute | null
pathState: "healthy" | "degraded" | "failover" | "unknown"
directWan: {
enabled: boolean
gateway: string | null
leasedIp: string | null
iface: string | null
provider: string | null
}
}
export interface RouteLookupResult {
gateway: string | null
routingMark: string | null
}
export interface HomeWanRuntime {
defaultGateway: string | null
defaultInterface: string | null
uplinks: Array<{
id: string
iface: string
name: string
isp: string
configuredIp: string
leasedIp: string | null
dhcpStatus: string | null
isDefault: boolean
}>
}
type DashboardPathProbe = RouteOptimizerSpeedProbe & {lastRunAt?: string | null; lastStatus?: string | null; lastError?: string | null; lastPingError?: string | null}
export function dashboardPathMetrics(probe: DashboardPathProbe | null, reverse = false): InternetPathHop["wanJhMetrics"] {
const dated=(time: string | null | undefined)=>Boolean(time)&&Number.isFinite(Date.parse(time!))
const known=(value: number | null | undefined)=>typeof value==="number"&&Number.isFinite(value)&&value>=0?value:null
const ping=probe?.enabled && dated(probe.lastPingAt) && (probe.lastPingError===null||probe.lastPingError==="") ? known(probe.lastPingRttMs) : null
const speed=probe?.enabled && dated(probe.lastRunAt) && probe.lastStatus==="done" && (probe.lastError===null||probe.lastError==="")
const dl=speed?known(reverse?probe.lastTxAvgMbps:probe.lastRxAvgMbps):null
const ul=speed?known(reverse?probe.lastRxAvgMbps:probe.lastTxAvgMbps):null
return {pingMs:ping,dlMbps:dl,ulMbps:ul,fromMonitoring:ping!==null||dl!==null||ul!==null}
}
const INTERNET_TARGET = "1.1.1.1"
function norm(v: string | null | undefined): string {
return String(v ?? "").trim().toLowerCase()
}
function parseRouteLookupOutput(output: string): RouteLookupResult {
const lines = output.split(/\r?\n/)
let gateway: string | null = null
let routingMark: string | null = null
for (const line of lines) {
const g = line.match(/^\s*gateway:\s*(.+?)\s*$/i)
if (g) gateway = g[1].trim()
const rm = line.match(/^\s*routing-mark:\s*(.+?)\s*$/i)
if (rm) routingMark = rm[1].trim()
}
return { gateway, routingMark }
}
function chooseActiveWan(
home: Server,
lookup: RouteLookupResult | null,
runtime: HomeWanRuntime | null,
): WanUplink | null {
const wans = home.wanUplinks ?? []
if (!wans.length) return null
// 1) Истина из MikroTik /ip/route + /ip/dhcp-client (wan-runtime)
if (runtime) {
const byDefaultFlag = runtime.uplinks.find((u) => u.isDefault)
if (byDefaultFlag) {
const byId = wans.find((w) => norm(w.id) === norm(byDefaultFlag.id))
if (byId) return byId
const byIface = wans.find((w) => norm(w.iface) === norm(byDefaultFlag.iface))
if (byIface) return byIface
}
if (runtime.defaultInterface) {
const byIface = wans.find((w) => norm(w.iface) === norm(runtime.defaultInterface))
if (byIface) return byIface
}
const runtimeByIface = runtime.uplinks.find((u) => norm(u.iface) === norm(runtime.defaultInterface))
if (runtimeByIface) {
const byId = wans.find((w) => norm(w.id) === norm(runtimeByIface.id))
if (byId) return byId
}
if (runtime.defaultGateway) {
const byGatewayIp = wans.find((w) => norm(w.ip) === norm(runtime.defaultGateway))
if (byGatewayIp) return byGatewayIp
}
}
// 2) Fallback: route-lookup
const gw = norm(lookup?.gateway)
if (gw) {
const byIface = wans.find((w) => Boolean(norm(w.iface)) && (norm(w.iface) === gw || gw.includes("%"+norm(w.iface))))
if (byIface) return byIface
const byIp = wans.find((w) => Boolean(norm(w.ip)) && (norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)))
if (byIp) return byIp
}
return null
}
function bestJhForWan(
home: Server,
wan: WanUplink,
jhs: Server[],
probes: DashboardPathProbe[],
): Server | null {
const pool = probes.filter((p) => p.srcServerId === home.id && p.enabled !== false)
const ranked = jhs
.map((jh) => {
const m = pool
.filter((p) => p.dstServerId === jh.id)
.filter((p) => {
const iface = norm(p.srcInterface)
return !iface || iface === norm(wan.iface)
})
const best = m.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
const rtt = best?.lastPingRttMs ?? jh.latency ?? 9999
return { jh, rtt }
})
.sort((a, b) => a.rtt - b.rtt)
return ranked[0]?.jh ?? null
}
function bestExitForJh(jh: Server, exits: Server[], probes: DashboardPathProbe[]): Server | null {
const pool = probes.filter((p) => p.enabled !== false)
const ranked = exits
.map((ex) => {
const best = pool
.filter((p) => (
(p.srcServerId === jh.id && p.dstServerId === ex.id) ||
(p.srcServerId === ex.id && p.dstServerId === jh.id)
))
.sort((a, b) => (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999))[0]
const rtt = best?.lastPingRttMs ?? ex.latency ?? 9999
return { ex, rtt }
})
.sort((a, b) => a.rtt - b.rtt)
return ranked[0]?.ex ?? null
}
function pickHomeJhProbe(
homeId: string,
wanIface: string,
jhId: string,
probes: DashboardPathProbe[],
): RouteOptimizerSpeedProbe | null {
const ifaceNorm = norm(wanIface)
const list = probes
.filter((p) => p.enabled !== false)
.filter((p) => p.srcServerId === homeId && p.dstServerId === jhId)
.filter((p) => {
const srcIf = norm(p.srcInterface)
return !ifaceNorm || !srcIf || srcIf === ifaceNorm
})
.sort((a, b) => {
const aHasPing = a.lastPingRttMs != null ? 1 : 0
const bHasPing = b.lastPingRttMs != null ? 1 : 0
if (bHasPing !== aHasPing) return bHasPing - aHasPing
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
})
return list[0] ?? null
}
function pickJhExitProbe(jhId: string, exitId: string, probes: DashboardPathProbe[]): RouteOptimizerSpeedProbe | null {
const list = probes
.filter((p) => p.enabled !== false)
.filter((p) => (
(p.srcServerId === jhId && p.dstServerId === exitId) ||
(p.srcServerId === exitId && p.dstServerId === jhId)
))
.sort((a, b) => {
const aHasPing = a.lastPingRttMs != null ? 1 : 0
const bHasPing = b.lastPingRttMs != null ? 1 : 0
if (bHasPing !== aHasPing) return bHasPing - aHasPing
return (a.lastPingRttMs ?? 9999) - (b.lastPingRttMs ?? 9999)
})
return list[0] ?? null
}
export function buildDashboardInternetPath(args: {
servers: Server[]
greTunnels: GreTunnel[]
probes: DashboardPathProbe[]
filtersRulesets: FiltersRulesetRow[]
routeLookupByServerId: Record<string, RouteLookupResult | null>
wanRuntimeByHomeId?: Record<string, HomeWanRuntime | null>
}): InternetPathViewModel | null {
const homes = args.servers.filter((s) => s.type === "home-router" && s.enabled)
const jhs = args.servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
const exits = args.servers.filter((s) => s.type === "exit-node" && s.enabled && s.status !== "offline")
const home = homes[0]
if (!home || !jhs.length || !exits.length) return null
const runtime = args.wanRuntimeByHomeId?.[home.id] ?? null
const optimizerRows: OptimizerApiServer[] = args.servers.map((s) => ({
id: Number.parseInt(s.id, 10) || 0,
name: s.name,
host: s.host,
type: s.type,
site: s.site,
country: s.country,
enabled: s.enabled,
status: s.status === "unknown" ? null : s.status === "degraded" ? "online" : s.status,
latency: s.latency,
model: s.model ?? null,
wanUplinks: (s.wanUplinks ?? []).map((w) => ({ ...w })),
}))
const optimizerData = buildLiveOptimizerData(
optimizerRows,
args.filtersRulesets,
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
args.probes,
)
const homeOptimizer = optimizerData.homes.find((h) => h.home.id === home.id)
const best = homeOptimizer?.bestRoute ?? null
const primaryPath: InternetPathRoute | null = best
? {
wanId: best.wan.id,
jhId: best.jh.id,
exitId: best.exit.id,
reason: "Route optimizer / OSPF quality",
}
: null
const observedRoute=Boolean(runtime?.defaultGateway||args.routeLookupByServerId[home.id]?.gateway)
const routeKnown=runtime!=null&&args.routeLookupByServerId[home.id]!=null
const activeWan = observedRoute ? chooseActiveWan(
home,
args.routeLookupByServerId[home.id] ?? null,
runtime,
) : null
const currentJh = activeWan ? bestJhForWan(home, activeWan, jhs, args.probes) : null
const currentExit = currentJh ? bestExitForJh(currentJh, exits, args.probes) : null
const currentPath: InternetPathRoute | null = activeWan
? {
wanId: activeWan.id,
reason: `Default route 0.0.0.0/0 (main) via ${activeWan.iface || activeWan.ip || "WAN"}`,
}
: null
const pathState: InternetPathViewModel["pathState"] =
!routeKnown||home.status==="unknown"||(currentJh?.status==="unknown")||(currentExit?.status==="unknown")?"unknown":primaryPath && currentPath
// Нормальный production-кейс: часть трафика идет через BGP (JH→EN),
// часть — напрямую в провайдера WAN uplink.
? "healthy"
: (!primaryPath && !currentPath ? "unknown" : "degraded")
const primaryWan = (home.wanUplinks ?? []).find((w) => w.id === primaryPath?.wanId) ?? null
const primaryJh = args.servers.find((s) => s.id === primaryPath?.jhId) ?? null
const primaryEx = args.servers.find((s) => s.id === primaryPath?.exitId) ?? null
const currentWan = (home.wanUplinks ?? []).find((w) => w.id === currentPath?.wanId) ?? activeWan
const primaryHop: InternetPathHop | null =
primaryWan && primaryJh && primaryEx
? {
home,
wan: primaryWan,
jumpHost: primaryJh,
exitNode: primaryEx,
wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id, primaryWan.iface, primaryJh.id, args.probes)),
jhExitMetrics: (()=>{const p=pickJhExitProbe(primaryJh.id,primaryEx.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==primaryJh.id)})(),
}
: null
const currentHop: InternetPathHop | null =
currentWan && currentJh && currentExit
? {
home,
wan: currentWan,
jumpHost: currentJh,
exitNode: currentExit,
wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id,currentWan.iface,currentJh.id,args.probes)),
jhExitMetrics: (()=>{const p=pickJhExitProbe(currentJh.id,currentExit.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==currentJh.id)})(),
}
: null
return {
homeRouter: home,
activeWanUplink: activeWan,
fallbackJumpHost: currentJh ?? primaryJh ?? jhs[0] ?? null,
fallbackExitNode: currentExit ?? primaryEx ?? exits[0] ?? null,
primaryHop,
currentHop,
primaryPath,
currentPath,
pathState,
directWan: {
enabled: Boolean(runtime?.defaultGateway || args.routeLookupByServerId[home.id]?.gateway),
gateway: runtime?.defaultGateway ?? args.routeLookupByServerId[home.id]?.gateway ?? null,
iface: runtime?.defaultInterface ?? activeWan?.iface ?? null,
leasedIp:
runtime?.uplinks.find((u) => u.isDefault)?.leasedIp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.leasedIp
?? null,
provider:
runtime?.uplinks.find((u) => u.isDefault)?.isp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.isp
?? activeWan?.isp
?? null,
},
}
}
export async function resolveDefaultRouteLookup(
apiFetch: <T>(path: string, init?: RequestInit) => Promise<T>,
serverId: string,
): Promise<RouteLookupResult | null> {
const payload = await apiFetch<unknown>(`/api/servers/${serverId}/probes/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tool: "route",
target: INTERNET_TARGET,
}),
})
return parseRouteLookupOutput(readProbeOutput(payload))
}
+65
View File
@@ -0,0 +1,65 @@
import type { PingProbe, Server } from "@/lib/data"
/** Длина ряда на графике дашборда (ось времени). */
export const DASH_LATENCY_POINTS = 60
export interface DashboardLatencyChartInput {
series: Record<string, (number|null)[]>
labels: Record<string, string>
}
/**
* Средний RTT по измеренным общим корзинам включённых проб одного источника.
* Не интерполируем и не подменяем отсутствующие измерения нулями.
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
* Ограничение числа линий — чтобы график оставался читаемым.
*/
export function buildLatencySeriesByProbeSource(
probes: Array<Pick<PingProbe,"enabled"|"srcServerId">&{series:(number|null)[]}>,
servers: Server[],
opts?: { maxServers?: number; points?: number },
): DashboardLatencyChartInput {
const maxServers = opts?.maxServers ?? 8
const points = opts?.points ?? DASH_LATENCY_POINTS
const bySrv = new Map<string, Array<Pick<PingProbe,"enabled"|"srcServerId">&{series:(number|null)[]}>>()
for (const p of probes) {
if (!p.enabled || !p.series?.length) continue
const sid = p.srcServerId
if (!bySrv.has(sid)) bySrv.set(sid, [])
bySrv.get(sid)!.push(p)
}
const sortedIds = [...bySrv.keys()].sort((a, b) => {
const na = servers.find((s) => s.id === a)?.name ?? a
const nb = servers.find((s) => s.id === b)?.name ?? b
return na.localeCompare(nb, "ru")
}).slice(0, maxServers)
const series: Record<string, (number|null)[]> = {}
const labels: Record<string, string> = {}
for (const sid of sortedIds) {
const plist = bySrv.get(sid)!
const innerLen = plist.reduce((m, p) => Math.max(m, p.series.length), 0)
if (innerLen === 0) continue
const averaged = Array.from({ length: Math.min(innerLen,points) }, (_, i) => {
const vals=plist.map(p=>p.series[i])
if(vals.some(v=>typeof v!=="number"||!Number.isFinite(v)))return null
const mean=vals.reduce<number>((sum,v)=>sum+(v??0),0)/vals.length
return Number.isFinite(mean)?Math.round(mean):null
})
if(!averaged.some(v=>v!==null))continue
const key = "src-"+sid
series[key] = averaged
const srv = servers.find((s) => s.id === sid)
const site = srv?.site?.trim()
labels[key] =
site && site !== "—"
? site.slice(0, 16)
: (srv?.name ?? `#${sid}`).slice(0, 16)
}
return { series, labels }
}
+8
View File
@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardGre,parseDashboardWireGuard,parseDashboardVxlan} from "./dashboard-overlays"
import {dashboardGre,dashboardWg,dashboardVxlan} from "../tests/e2e/fixtures/dashboard-overlays"
for(const [kind,parse,key,row] of [["GRE",parseDashboardGre,"tunnels",dashboardGre],["VXLAN",parseDashboardVxlan,"tunnels",dashboardVxlan],["WG",parseDashboardWireGuard,"interfaces",dashboardWg]] as const)test("strict complete dashboard overlay catalog/"+kind,()=>{
assert.ok(parse({[key]:[row],failures:[]}));assert.ok(parse({[key]:[],failures:[]}))
for(const raw of [{},{[key]:[]},{[key]:[{}],failures:[]},{[key]:[row,row],failures:[]},{[key]:[{...row,status:"unknown"}],failures:[]},{[key]:[row],failures:[{serverId:"2",error:"timeout"}]}])assert.throws(()=>parse(raw))
})
+13
View File
@@ -0,0 +1,13 @@
import {greResponseSchema} from "@/lib/gre-response"
import {vxlanResponseSchema} from "@/lib/vxlan-response"
import {wgListResponseSchema} from "@mmapp/contracts/wireguard"
import type {z} from "zod"
function complete(raw:unknown,failures:Array<{serverId:string;serverName?:string;error:string}>,kind:string){
if(!raw||typeof raw!=="object"||!("failures" in raw)||!Array.isArray(raw.failures))throw Error("Результат опроса устройств "+kind+" не подтверждён")
if(new Set(failures.map(f=>f.serverId)).size!==failures.length||failures.some(f=>!f.serverId.trim()))throw Error("Некорректные ошибки каталога "+kind)
if(failures.length)throw Error("Не опрошены устройства "+kind+": "+failures.map(f=>(f.serverName??f.serverId)+": "+f.error).join("; "))
}
export type DashboardGreTunnel=z.infer<typeof greResponseSchema>["tunnels"][number]
export function parseDashboardGre(raw:unknown){const p=greResponseSchema.safeParse(raw);if(!p.success)throw Error("Некорректный каталог GRE");complete(raw,p.data.failures,"GRE");return p.data}
export function parseDashboardVxlan(raw:unknown){const p=vxlanResponseSchema.safeParse(raw);if(!p.success)throw Error("Некорректный каталог VXLAN");complete(raw,p.data.failures,"VXLAN");return p.data}
export function parseDashboardWireGuard(raw:unknown){const p=wgListResponseSchema.safeParse(raw);if(!p.success)throw Error("Некорректный каталог WireGuard");complete(raw,p.data.failures??[],"WireGuard");if(new Set(p.data.interfaces.map(i=>JSON.stringify([i.serverId,i.id]))).size!==p.data.interfaces.length||new Set(p.data.interfaces.map(i=>JSON.stringify([i.serverId,i.rosId]))).size!==p.data.interfaces.length)throw Error("Повторяющийся интерфейс WireGuard");return p.data}
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardPath,parseDashboardSpeedProbes,parseDashboardWanRuntime,parseDashboardFilters} from "./dashboard-path-response"
import {resolveDefaultRouteLookup,buildDashboardInternetPath,dashboardPathMetrics} from "./dashboard-internet-path"
import {servers} from "./data"
const snapshot={collectionComplete:true,sampledAt:"2026-10-03T00:00:00Z",servers:[],greTunnels:[],filtersRulesets:[],speedProbes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}}
test("path validates explicit empty snapshot and rejects missing fields, foreign IDs and mismatched time",()=>{
assert.deepEqual(parseDashboardPath({snapshot:null}),{snapshot:null});assert.ok(parseDashboardPath({snapshot}))
for(const raw of [{snapshot:{...snapshot,collectionComplete:undefined}},{snapshot:{}},{snapshot:null,sampledAt:snapshot.sampledAt},{snapshot:{...snapshot,speedProbes:undefined}},{snapshot:{...snapshot,routeLookupByServerId:{"99":null}}},{snapshot,sampledAt:"2026-10-03T01:00:00Z"}])assert.throws(()=>parseDashboardPath(raw))
})
test("path fallback validates rules, speed probes and WAN rather than silently using empty data",()=>{
assert.ok(parseDashboardSpeedProbes({probes:[]}));assert.ok(parseDashboardFilters({rulesets:[]}));assert.ok(parseDashboardWanRuntime({defaultGateway:null,defaultInterface:null,uplinks:[]}))
for(const work of [()=>parseDashboardSpeedProbes({}),()=>parseDashboardFilters({rulesets:[],stale:true}),()=>parseDashboardWanRuntime({}),()=>parseDashboardWanRuntime({defaultGateway:null,defaultInterface:null,uplinks:[{}]})])assert.throws(work)
})
test("route diagnostic rejects malformed and error output; no route is a confirmed empty result",async()=>{
for(const raw of [{},{output:"Error: denied"},{output:12}])await assert.rejects(resolveDefaultRouteLookup(async<T>()=>raw as T,"1"))
assert.deepEqual(await resolveDefaultRouteLookup(async<T>()=>({output:""}) as T,"1"),{gateway:null,routingMark:null})
})
test("configured WAN alone does not prove an observed current route",()=>{
const mapped=servers.map((s,i)=>({...s,id:String(i+1)}));const result=buildDashboardInternetPath({servers:mapped,greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}})
assert.ok(result);assert.equal(result.pathState,"unknown");assert.equal(result.currentPath,null);assert.equal(result.activeWanUplink,null)
})
test("path metrics use dated successful hop observations, preserve RX/TX and reject configured fallback",()=>{
const p={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:5,lastPingAt:snapshot.sampledAt,lastRunAt:snapshot.sampledAt,lastStatus:"done",lastError:null,lastPingError:null}
assert.deepEqual(dashboardPathMetrics(p),{pingMs:0,dlMbps:5,ulMbps:0,fromMonitoring:true});assert.deepEqual(dashboardPathMetrics(p,true),{pingMs:0,dlMbps:0,ulMbps:5,fromMonitoring:true})
assert.deepEqual(dashboardPathMetrics(null),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
assert.deepEqual(dashboardPathMetrics({...p,lastRunAt:null,lastPingAt:null}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
assert.deepEqual(dashboardPathMetrics({...p,lastStatus:"error",lastPingError:"denied"}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
assert.equal(dashboardPathMetrics({...p,lastRxAvgMbps:null}).dlMbps,null)
assert.deepEqual(dashboardPathMetrics({...p,enabled:false}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false})
})
test("observed default route does not select the first WAN or a merely bound DHCP lease",()=>{
const home={...servers.find(s=>s.type==="home-router")!,id:"1"};const runtime={defaultGateway:"203.0.113.254",defaultInterface:"unknown-interface",uplinks:home.wanUplinks!.map(w=>({id:w.id,iface:w.iface,name:w.name,isp:w.isp,configuredIp:w.ip,leasedIp:w.ip,dhcpStatus:"bound",isDefault:false}))}
const result=buildDashboardInternetPath({servers:[home,...servers.filter(s=>s.type!=="home-router")],greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{"1":{gateway:"203.0.113.254",routingMark:null}},wanRuntimeByHomeId:{"1":runtime}})
assert.equal(result?.activeWanUplink,null);assert.equal(result?.currentPath,null);assert.equal(result?.directWan.gateway,"203.0.113.254")
})
+18
View File
@@ -0,0 +1,18 @@
import {z} from "zod"
import {dashboardServersSchema} from "@/lib/dashboard-readers"
import {greResponseSchema} from "@/lib/gre-response"
import {filtersResponseSchema} from "@/lib/filters-response"
import {optimizerProbesSchema,optimizerRulesSchema} from "@/lib/route-optimizer-response"
const route=z.object({gateway:z.string().nullable(),routingMark:z.string().nullable()})
export const dashboardWanRuntimeSchema=z.object({defaultGateway:z.string().nullable(),defaultInterface:z.string().nullable(),uplinks:z.array(z.object({id:z.string().min(1),iface:z.string(),name:z.string(),isp:z.string(),configuredIp:z.string(),leasedIp:z.string().nullable(),dhcpStatus:z.string().nullable(),isDefault:z.boolean()})).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
const pathProbe=optimizerProbesSchema.shape.probes.element.extend({lastRunAt:z.iso.datetime({offset:true}).nullable().optional(),lastStatus:z.enum(["done","error"]).nullable().optional(),lastError:z.string().nullable().optional(),lastPingError:z.string().nullable().optional()})
const pathProbes=z.array(pathProbe).refine(rows=>new Set(rows.map(p=>p.id)).size===rows.length)
const snapshot=z.object({collectionComplete:z.literal(true),sampledAt:z.iso.datetime({offset:true}),servers:dashboardServersSchema,greTunnels:greResponseSchema.shape.tunnels,filtersRulesets:filtersResponseSchema.shape.rulesets,speedProbes:pathProbes,routeLookupByServerId:z.record(z.string(),route.nullable()),wanRuntimeByHomeId:z.record(z.string(),dashboardWanRuntimeSchema.nullable())}).refine(data=>{
const ids=new Set(data.servers.map(s=>String(s.id))),homes=data.servers.filter(s=>s.enabled&&s.type==="home-router")
return data.greTunnels.every(t=>ids.has(t.serverId))&&data.filtersRulesets.every(r=>ids.has(r.serverId))&&data.speedProbes.every(p=>ids.has(p.srcServerId)&&ids.has(p.dstServerId))&&Object.keys(data.routeLookupByServerId).every(id=>ids.has(id))&&Object.keys(data.wanRuntimeByHomeId).every(id=>ids.has(id))&&homes.every(h=>data.routeLookupByServerId[String(h.id)]!=null&&data.wanRuntimeByHomeId[String(h.id)]!=null)
})
const response=z.object({snapshot:snapshot.nullable(),sampledAt:z.iso.datetime({offset:true}).optional()}).refine(data=>data.snapshot===null?data.sampledAt===undefined:!data.sampledAt||Date.parse(data.sampledAt)===Date.parse(data.snapshot.sampledAt))
export function parseDashboardPath(raw:unknown){const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Снимок Internet path не подтверждён: данные неполны или повреждены");return parsed.data}
export function parseDashboardFilters(raw:unknown){const parsed=optimizerRulesSchema.safeParse(raw);if(!parsed.success||parsed.data.error)throw Error("Данные Filters для Internet path не подтверждены");return parsed.data}
export function parseDashboardSpeedProbes(raw:unknown){const parsed=z.object({probes:pathProbes}).safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости для Internet path не подтверждены");return parsed.data}
export function parseDashboardWanRuntime(raw:unknown){const parsed=dashboardWanRuntimeSchema.safeParse(raw);if(!parsed.success)throw Error("Данные WAN для Internet path не подтверждены");return parsed.data}
+8
View File
@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {dashboardRequestPermission} from "./dashboard-permissions"
import {permissionForRequest} from "../backend/src/lib/permissions"
test("dashboard reader gates match real backend permissions including fallback diagnostic POST",()=>{
for(const [method,path] of [["GET","/api/uptime/overview?range=1h"],["GET","/api/servers"],["GET","/api/filters/rules"],["GET","/api/bgp/catalog"],["GET","/api/internet-path/latest"],["GET","/api/filters/gre-tunnels"],["GET","/api/wireguard"],["GET","/api/traffic/servers?range=1h"],["GET","/api/vxlan"],["GET","/api/events?limit=6"],["GET","/api/servers/1/wan-runtime"],["POST","/api/servers/1/probes/run"],["GET","/api/uptime/speed-probes"]])assert.equal(dashboardRequestPermission(method,path),permissionForRequest(method,path))
assert.throws(()=>dashboardRequestPermission("GET","/api/unmapped"));assert.throws(()=>dashboardRequestPermission("POST","/api/events"))
})
+12
View File
@@ -0,0 +1,12 @@
/** Limited to API paths consumed by useDashboardLive; matches backend request gates. */
export function dashboardRequestPermission(method:string,path:string):string{
const pathname=path.split("?")[0],write=method.toUpperCase()!=="GET"
if(pathname.startsWith("/api/servers"))return write?"mm:servers:write":"mm:servers:read"
if(pathname.startsWith("/api/uptime"))return write?"mm:uptime:write":"mm:uptime:read"
if(pathname.startsWith("/api/filters"))return write?"mm:filters:write":"mm:filters:read"
if(pathname.startsWith("/api/bgp"))return write?"mm:bgp:write":"mm:bgp:read"
if(pathname.startsWith("/api/traffic"))return write?"mm:traffic:write":"mm:traffic:read"
if(pathname.startsWith("/api/internet-path")||pathname.startsWith("/api/wireguard"))return write?"mm:network:write":"mm:network:read"
if(!write&&(pathname==="/api/vxlan"||pathname.startsWith("/api/events")))return "mm:dashboard:read"
throw Error("Неизвестный источник дашборда")
}
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardProbes} from "./dashboard-probes"
import {buildLatencySeriesByProbeSource} from "./dashboard-latency"
const start="2026-10-03T00:00:00Z",end="2026-10-03T01:00:00Z"
const base={id:"p1",srcServerId:"1",name:"Проверка",target:"192.0.2.1",filter:"main",rtt:0,loss:0,status:"up",series:[0],enabled:true,showOnDashboard:true}
const sampled={sampledAt:"2026-10-03T00:30:00Z",rtt:0,loss:0,status:"up"}
const payload=(samples:unknown[])=>({rangeStartAt:start,rangeEndAt:end,probes:[{...base,observations:{samples}}]})
test("ping legacy and empty samples do not prove up, down, loss or historical zero",()=>{
for(const raw of [{probes:[base]},payload([])]){const p=parseDashboardProbes(raw).probes[0];assert.equal(p.status,"unknown");assert.equal(p.rtt,null);assert.equal(p.loss,null);assert.ok(p.series.every(v=>v===null))}
assert.deepEqual(parseDashboardProbes({probes:[]}),{probes:[],sampledAt:null})
})
test("ping observed zero and failure retain independent measurement values and sparse time axis",()=>{
const p=parseDashboardProbes(payload([sampled])).probes[0];assert.equal(p.status,"up");assert.equal(p.rtt,0);assert.equal(p.loss,0);assert.equal(p.series[30],0);assert.equal(p.series.filter(v=>v!==null).length,1)
const failed=parseDashboardProbes(payload([sampled,{...sampled,sampledAt:"2026-10-03T00:31:00Z",rtt:null,loss:100,status:"down"}])).probes[0];assert.equal(failed.status,"down");assert.equal(failed.loss,100);assert.equal(failed.series[31],null)
assert.deepEqual(buildLatencySeriesByProbeSource([p],[]).series["src-1"],p.series)
assert.deepEqual(buildLatencySeriesByProbeSource([p,{...p,srcServerId:"1",series:Array(60).fill(null)}],[]).series,{})
})
test("ping invalid time axis, duplicate or out of range samples cannot replace cache",()=>{
for(const raw of [payload([sampled,sampled]),payload([{...sampled,sampledAt:"invalid"}]),payload([{...sampled,sampledAt:"2026-10-02T00:00:00Z"}]),{...payload([sampled]),rangeEndAt:start},{...payload([sampled]),rangeStartAt:undefined},payload([{...sampled,loss:101}])])assert.throws(()=>parseDashboardProbes(raw))
})
test("failed execution is an unknown observation, while measured 100 percent loss remains down",()=>{
const failed={...sampled,sampledAt:"2026-10-03T00:31:00Z",rtt:null,loss:null,status:"unknown",error:"API denied"}
const p=parseDashboardProbes(payload([sampled,failed])).probes[0]
assert.equal(p.status,"unknown");assert.equal(p.rtt,null);assert.equal(p.loss,null);assert.equal(p.observationError,"API denied");assert.equal(p.series[31],null)
assert.throws(()=>parseDashboardProbes(payload([{...failed,loss:100}])))
})
+25
View File
@@ -0,0 +1,25 @@
import {z} from "zod"
import type {PingProbe} from "@/lib/data"
const date=z.iso.datetime({offset:true})
const sample=z.object({sampledAt:date,rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),error:z.string().optional()}).refine(s=>s.status==="unknown"?s.rtt===null&&s.loss===null:s.loss!==null)
const probe=z.object({id:z.string().trim().min(1),srcServerId:z.string().trim().min(1),srcInterface:z.string().optional(),name:z.string(),target:z.string(),filter:z.string(),rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),series:z.array(z.number().finite().nonnegative().nullable()),enabled:z.boolean(),showOnDashboard:z.boolean(),observations:z.object({samples:z.array(sample)}).optional()})
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length),rangeStartAt:date.optional(),rangeEndAt:date.optional()}).refine(raw=>{
if(!raw.probes.some(p=>p.observations))return true
if(!raw.rangeStartAt||!raw.rangeEndAt)return false
const start=Date.parse(raw.rangeStartAt),end=Date.parse(raw.rangeEndAt)
return end>start&&raw.probes.every(p=>{const samples=p.observations?.samples??[];return new Set(samples.map(s=>Date.parse(s.sampledAt))).size===samples.length&&samples.every((s,i)=>Date.parse(s.sampledAt)>=start&&Date.parse(s.sampledAt)<=end&&(i===0||Date.parse(s.sampledAt)>Date.parse(samples[i-1].sampledAt)))})
})
export type DashboardProbe=Omit<PingProbe,"status"|"loss"|"series">&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean;observationError?:string|null}
function meanRtt(values:Array<number|null>){const mean=values.reduce<number>((sum,v)=>sum+(v??0),0)/values.length;if(!Number.isFinite(mean))throw Error("Средний RTT превышает допустимый диапазон");return Math.round(mean)}
export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampledAt:string|null}{
const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб")
const data=parsed.data,start=data.rangeStartAt?Date.parse(data.rangeStartAt):0,end=data.rangeEndAt?Date.parse(data.rangeEndAt):0
const probes:DashboardProbe[]=data.probes.map(p=>{const samples=p.observations?.samples??[],last=samples.at(-1);const buckets:Array<Array<number|null>>=Array.from({length:60},()=>[])
for(const s of samples){const i=Math.min(59,Math.floor((Date.parse(s.sampledAt)-start)/(end-start)*60));buckets[i].push(s.rtt)}
return {...p,status:last?.status??"unknown",rtt:last?.rtt??null,loss:last?.loss??null,sampledAt:last?.sampledAt??null,historyObserved:!!p.observations,observationError:last?.error??null,series:buckets.map(values=>!values.length||values.some(v=>v===null)?null:meanRtt(values))}
})
const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v)
return {probes,sampledAt:active.length&&times.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
}
export { overview as dashboardProbeOverviewSchema }
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardServers,parseDashboardProbes} from "./dashboard-readers"
const server={id:1,name:"Узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:null,latency:null,os:null,model:null,sessions:0}
const probe={id:"p1",srcServerId:"1",name:"Проверка",target:"192.0.2.2",filter:"main",rtt:null,loss:100,status:"down",series:[],enabled:true,showOnDashboard:true}
test("dashboard server catalog validates identities and keeps unknown status",()=>{
assert.equal(parseDashboardServers([server])[0].status,null);assert.deepEqual(parseDashboardServers([]),[])
for(const raw of [{},[server,server],[{...server,status:"invalid"}],[{...server,latency:-1}],[{...server,id:0}],[{id:1}]])assert.throws(()=>parseDashboardServers(raw))
})
test("dashboard probes validate real shape, uniqueness and numeric ranges before publication",()=>{
assert.equal(parseDashboardProbes({probes:[probe]}).probes[0].rtt,null);assert.deepEqual(parseDashboardProbes({probes:[]}),{probes:[],sampledAt:null})
const legacyGap=parseDashboardProbes({probes:[{...probe,series:[null]}]}).probes[0]
assert.equal(legacyGap.status,"unknown");assert(legacyGap.series.every(v=>v===null))
for(const raw of [{},{probes:[probe,probe]},{probes:[{...probe,loss:101}]},{probes:[{...probe,status:"invalid"}]},{probes:[{...probe,series:[-1]}]},{probes:[{...probe,series:[NaN]}]},{probes:[{}]}])assert.throws(()=>parseDashboardProbes(raw))
})
+7
View File
@@ -0,0 +1,7 @@
import {z} from "zod"
import {serverReadSchema} from "@mmapp/contracts/servers"
const server=serverReadSchema.pick({id:true,name:true,host:true,site:true,country:true,asn:true,type:true,enabled:true,status:true,latency:true,os:true,model:true}).extend({id:z.number().int().positive(),host:z.string().trim().min(1),latency:z.number().finite().nonnegative().nullable(),sessions:z.number().int().nonnegative(),wanUplinks:serverReadSchema.shape.wanUplinks.optional()})
export const dashboardServersSchema=z.array(server).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)
export {parseDashboardProbes} from "@/lib/dashboard-probes"
export type DashboardServerRow=z.infer<typeof server>
export function parseDashboardServers(raw:unknown){const parsed=dashboardServersSchema.safeParse(raw);if(!parsed.success)throw Error("Некорректный каталог серверов");return parsed.data}
+12
View File
@@ -0,0 +1,12 @@
export const DASHBOARD_SOURCES = {
servers: "Серверы", probes: "Пробы", bgp: "BGP", gre: "GRE",
wg: "WireGuard", vxlan: "VXLAN", traffic: "Трафик",
internetPath: "Internet path", events: "События",
} as const
export type DashboardSource = keyof typeof DASHBOARD_SOURCES
export type DashboardSources = Partial<Record<DashboardSource, { receivedAt: number | null; error: string | null; sampledAt?: string }>>
export function updateDashboardSource(previous: DashboardSources, source: DashboardSource, error: string | null, now: number, sampledAt?: string): DashboardSources {
return { ...previous, [source]: { receivedAt: error ? previous[source]?.receivedAt ?? null : now, error, sampledAt: error ? previous[source]?.sampledAt : sampledAt } }
}
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardTraffic} from "./dashboard-traffic"
const row={id:"1",rxNow:2,txNow:1,rxSeries:[1,2],txSeries:[0,1],observations:{rxNow:true,txNow:true,rxSeries:[true,true],txSeries:[true,true],sampledAt:"2026-09-20T00:00:00Z"}}
test("traffic sums aligned observed series and does not invent sixty samples",()=>{
assert.deepEqual(parseDashboardTraffic({servers:[row,{...row,id:"2"}]}),{rx:[2,4],tx:[0,2],rxNow:4,txNow:2,observationsKnown:true,sampledAt:new Date(row.observations.sampledAt).toISOString()})
assert.deepEqual(parseDashboardTraffic({servers:[]}),{rx:[],tx:[],rxNow:0,txNow:0,observationsKnown:true,sampledAt:null})
assert.deepEqual(parseDashboardTraffic({servers:[{...row,rxSeries:[],txSeries:[],observations:{...row.observations,rxSeries:[],txSeries:[]}}]}),{rx:[],tx:[],rxNow:2,txNow:1,observationsKnown:true,sampledAt:new Date(row.observations.sampledAt).toISOString()})
})
test("missing, duplicate, null, invalid, misaligned and overflowing traffic cannot replace a snapshot",()=>{
for(const raw of [{},{servers:[row,row]},{servers:[{...row,rxNow:null}]},{servers:[{...row,txNow:-1}]},{servers:[{...row,rxSeries:[null,1]}]},{servers:[{...row,txSeries:[1]}]},{servers:[row,{...row,id:"2",rxSeries:[],txSeries:[]}]},{servers:[{...row,rxNow:Number.MAX_VALUE},{...row,id:"2",rxNow:Number.MAX_VALUE}]}])assert.throws(()=>parseDashboardTraffic(raw))
})
test("legacy rates and absent observations remain unknown while measured zero remains zero",()=>{
const legacy={...row,observations:undefined};assert.equal(parseDashboardTraffic({servers:[legacy]}).rxNow,null);assert.deepEqual(parseDashboardTraffic({servers:[legacy]}).rx,[null,null]);assert.equal(parseDashboardTraffic({servers:[legacy]}).observationsKnown,false)
const holes={...row,rxNow:0,txNow:0,rxSeries:[0,0],txSeries:[0,0],observations:{...row.observations,rxSeries:[false,true],txSeries:[false,true]}};const known=parseDashboardTraffic({servers:[holes]});assert.deepEqual(known.rx,[null,0]);assert.equal(known.rxNow,0)
assert.equal(parseDashboardTraffic({servers:[row,{...legacy,id:"2"}]}).rxNow,null)
for(const observations of [{...row.observations,rxSeries:[]},{...row.observations,sampledAt:null},{...row.observations,sampledAt:"invalid"}])assert.throws(()=>parseDashboardTraffic({servers:[{...row,observations}]}))
})
+18
View File
@@ -0,0 +1,18 @@
import {z} from "zod"
const rate=z.number().finite().nonnegative()
const observations=z.object({rxNow:z.boolean(),txNow:z.boolean(),rxSeries:z.array(z.boolean()),txSeries:z.array(z.boolean()),sampledAt:z.string().refine(v=>Number.isFinite(Date.parse(v))).nullable()})
const row=z.object({id:z.string().trim().min(1),rxNow:rate,txNow:rate,rxSeries:z.array(rate),txSeries:z.array(rate),observations:observations.optional()}).refine(r=>!r.observations||(r.observations.rxSeries.length===r.rxSeries.length&&r.observations.txSeries.length===r.txSeries.length&&(!(r.observations.rxNow||r.observations.txNow||r.observations.rxSeries.some(Boolean)||r.observations.txSeries.some(Boolean))||r.observations.sampledAt!==null)))
const payload=z.object({servers:z.array(row)}).refine(data=>new Set(data.servers.map(r=>r.id)).size===data.servers.length&&data.servers.every(r=>r.rxSeries.length===r.txSeries.length&&r.rxSeries.length===data.servers[0].rxSeries.length))
export interface DashboardTraffic {rx:(number|null)[];tx:(number|null)[];rxNow:number|null;txNow:number|null;observationsKnown:boolean;sampledAt:string|null}
/** Numeric legacy zero alone cannot confirm that the collector observed an interval. */
export function parseDashboardTraffic(raw:unknown):DashboardTraffic {
const parsed=payload.safeParse(raw)
if(!parsed.success)throw Error("Некорректные данные трафика: проверьте счётчики и согласованность серий")
const rows=parsed.data.servers,length=rows[0]?.rxSeries.length??0
const sum=(key:"rxNow"|"txNow")=>rows.every(r=>r.observations?.[key])?rows.reduce((sum,r)=>sum+r[key],0):null
const series=(key:"rxSeries"|"txSeries")=>Array.from({length},(_,i)=>rows.every(r=>r.observations?.[key][i])?rows.reduce((sum,r)=>sum+r[key][i],0):null)
const timestamps=rows.map(r=>r.observations?.sampledAt).filter((v):v is string=>!!v)
const total={rx:series("rxSeries"),tx:series("txSeries"),rxNow:sum("rxNow"),txNow:sum("txNow"),observationsKnown:rows.every(r=>!!r.observations),sampledAt:timestamps.length===rows.length&&timestamps.length?new Date(timestamps.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
if(![total.rxNow,total.txNow,...total.rx,...total.tx].every(v=>v===null||Number.isFinite(v)))throw Error("Суммарный трафик превышает допустимый диапазон")
return total
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { applyFilterQuery } from "./apply-filter-query"
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
const rows = [{ country: "RU", status: "online" }, { country: "DE", status: "offline" }, { country: "NL", status: "offline" }]
const accessors = { country: (r: typeof rows[number]) => r.country, status: (r: typeof rows[number]) => r.status }
const rule = (field: string, operator: string, value?: unknown) => createFilterRule({ id: field, path: [field], operator, value })
test("scalar select supports multiple allowed values and AND with status", () => {
const query = createFilterQuery([rule("country", "is_any_of", ["RU", "DE"]), rule("status", "is", "offline")])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("nested OR preserves grouping and negation", () => {
const query = createFilterQuery([createFilterQuery([rule("country", "is", "RU"), rule("country", "is", "DE")], "or", "countries"), { ...rule("status", "is", "online"), negated: true }])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("drafts do not make OR true, an entirely empty query keeps the list", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is", "RU"), rule("status", "is")], "or"), accessors), [rows[0]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery(), accessors), rows)
})
test("unknown fields and operators never broaden the selection", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "unsupported", "RU")]), accessors), [])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("unknown", "is", "RU")]), accessors), [])
})
test("negative selection and empty values stay distinct", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is_none_of", ["RU", "NL"])]), accessors), [rows[1]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "empty")]), accessors), [])
})
test("text prefix and suffix use case-insensitive matching and support negation", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "starts_with", "r")]), accessors), [rows[0]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "ends_with", "e")]), accessors), [rows[1]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([{ ...rule("country", "starts_with", "r"), negated: true }]), accessors), rows.slice(1))
})
test("scalar operators keep their expected results when combined with AND", () => {
const cases: [string, unknown, typeof rows][] = [
["is", "RU", []],
["is_not", "RU", [rows[1], rows[2]]],
["is_any_of", ["RU", "DE"], [rows[1]]],
["is_none_of", ["RU", "DE"], [rows[2]]],
["contains", "e", [rows[1]]],
["not_contains", "e", [rows[2]]],
]
for (const [operator, value, expected] of cases) {
const query = createFilterQuery([rule("country", operator, value), rule("status", "is_not", "online")])
assert.deepEqual(applyFilterQuery(rows, query, accessors), expected)
}
})
+44
View File
@@ -0,0 +1,44 @@
import type { FilterNode, FilterQuery, FilterRule } from "@/components/reui/filters/filters-types"
export type FilterAccessors<T> = Record<string, (row: T) => unknown>
/** Undefined is a draft, not a matching branch (especially inside OR). */
function matchRule<T>(row: T, rule: FilterRule, accessors: FilterAccessors<T>): boolean | undefined {
if (!rule.operator) return undefined
const accessor = accessors[rule.path.join(".")]
if (!accessor) return false
const actual = accessor(row)
const value = rule.value
const values = Array.isArray(value) ? value : value === undefined ? [] : [value]
const empty = actual == null || actual === "" || (Array.isArray(actual) && actual.length === 0)
if (!["empty", "not_empty"].includes(rule.operator) && (value === undefined || values.length === 0)) return undefined
const text = (v: unknown) => String(v ?? "").toLocaleLowerCase()
let result: boolean
switch (rule.operator) {
case "is": result = String(actual) === String(values[0]); break
case "is_not": result = String(actual) !== String(values[0]); break
case "is_any_of": result = values.some(v => String(actual) === String(v)); break
case "is_none_of": result = !values.some(v => String(actual) === String(v)); break
case "contains": result = values.some(v => text(actual).includes(text(v))); break
case "not_contains": result = !values.some(v => text(actual).includes(text(v))); break
case "starts_with": result = text(actual).startsWith(text(values[0])); break
case "ends_with": result = text(actual).endsWith(text(values[0])); break
case "empty": result = empty; break
case "not_empty": result = !empty; break
// Unsupported conditions must never silently widen the result set.
default: return false
}
return rule.negated ? !result : result
}
function matchNode<T>(row: T, node: FilterNode, accessors: FilterAccessors<T>): boolean | undefined {
if (node.type === "rule") return matchRule(row, node, accessors)
const results = node.rules.map(child => matchNode(row, child, accessors)).filter(value => value !== undefined)
if (results.length === 0) return undefined
return node.combinator === "and" ? results.every(Boolean) : results.some(Boolean)
}
/** Evaluate the tree recursively: flattening loses group combinators. */
export function applyFilterQuery<T>(rows: T[], query: FilterQuery, accessors: FilterAccessors<T>): T[] {
return rows.filter(row => matchNode(row, query, accessors) !== false)
}
+36
View File
@@ -0,0 +1,36 @@
import type { FilterField } from "@/components/reui/filters/filters-types"
import type { Backup } from "@/lib/data"
export const BACKUP_FILTER_FIELDS: FilterField[] = [
{
id: "server",
label: "Сервер",
type: "text",
placeholder: "Имя сервера",
},
{
id: "kind",
label: "Тип",
type: "select",
options: [
{ value: "auto", label: "авто" },
{ value: "manual", label: "вручную" },
],
},
{
id: "storage",
label: "Хранилище",
type: "select",
options: [
{ value: "local", label: "Локально" },
{ value: "s3", label: "S3" },
{ value: "both", label: "Локально + S3" },
],
},
]
export const BACKUP_FILTER_ACCESSORS = {
server: (b: Backup) => b.server,
kind: (b: Backup) => b.kind,
storage: (b: Backup) => b.storage,
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { emptyBackupQuery, encodeBackupQuery, patchBackupsUrl, readBackupsUrl, validateBackupQuery } from "./backups-url-state"
const query = () => ({ ...emptyBackupQuery(), rules: [{ id: "rule-1", type: "rule" as const, path: ["server"], operator: "contains", value: "Москва & /" }] })
test("backup context roundtrips text/select filters and retains foreign keys", () => {
const href = patchBackupsUrl(new URLSearchParams("keep=1"), { q: "Тест & /", kind: "manual", tab: "settings", filters: encodeBackupQuery(query()) })
const params = new URL(href, "https://example.test").searchParams, result = readBackupsUrl(params)
assert.equal(params.get("keep"), "1"); assert.equal(result.tab, "settings"); assert.equal(result.kind, "manual"); assert.equal(result.search, "Тест & /"); assert.deepEqual(result.query, query()); assert.equal(result.error, null)
const select = { ...query(), rules: [{ id: "rule-1", type: "rule" as const, path: ["storage"], operator: "is_any_of", value: ["s3", "both"] }] }
assert.equal(validateBackupQuery(select), "valid"); assert.equal(encodeBackupQuery(emptyBackupQuery()), null)
})
test("bad, duplicate and oversized URL parameters remain visible errors", () => {
for (const raw of ["tab=bad", "kind=bad", "kind=auto&kind=manual", "q=a&q=b", "filters={}", "filters=bad", "q=" + "x".repeat(201)]) assert.ok(readBackupsUrl(new URLSearchParams(raw)).error)
for (const patch of [{ path: ["unknown"] }, { operator: "gt" }, { value: 1 }, { path: ["storage"], operator: "is", value: "unknown" }]) assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], ...patch }] }), "invalid")
assert.equal(validateBackupQuery({ ...query(), rules: [query().rules[0], query().rules[0]] }), "invalid")
assert.equal(validateBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }), "draft"); assert.throws(() => encodeBackupQuery({ ...query(), rules: [{ ...query().rules[0], value: undefined }] }))
const tooMany = { ...query(), rules: Array.from({ length: 51 }, (_, i) => ({ ...query().rules[0], id: "r" + i })) }; assert.equal(validateBackupQuery(tooMany), "invalid")
})
+73
View File
@@ -0,0 +1,73 @@
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { BACKUP_FILTER_FIELDS } from "./backup-filter-fields"
export type BackupKindFilter = "all" | "auto" | "manual"
export type BackupsTab = "history" | "settings"
export const emptyBackupQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
const MAX_BYTES = 8192
const options = new Map(BACKUP_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))]))
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
export function validateBackupQuery(value: unknown): "valid" | "draft" | "invalid" {
const ids = new Set<string>()
let count = 0
let draft = false
function visit(node: unknown, depth: number): boolean {
if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
ids.add(node.id)
if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
const allowed = options.get(node.path[0])
if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false
if (node.operator === "" || node.operator === undefined) { draft = true; return true }
if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined
const text = node.path[0] === "server"
const operators = text ? ["contains", "not_contains", "starts_with", "ends_with", "is", "is_not"] : ["is", "is_not", "is_any_of", "is_none_of"]
if (!operators.includes(String(node.operator))) return false
if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true }
if (text) return typeof node.value === "string" && node.value.trim().length > 0 && node.value.length <= 256
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && allowed.has(node.value)
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
}
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
return draft ? "draft" : "valid"
}
export function encodeBackupQuery(query: FilterQuery): string | null {
if (validateBackupQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.")
if (query.rules.length === 0) return null
const encoded = JSON.stringify({ v: 1, query })
if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.")
return encoded
}
export function readBackupsUrl(params: URLSearchParams) {
const search = params.get("q") ?? "", rawKind = params.get("kind"), rawTab = params.get("tab")
const kind: BackupKindFilter = rawKind === "auto" || rawKind === "manual" ? rawKind : "all"
const tab: BackupsTab = rawTab === "settings" ? "settings" : "history"
let error: string | null = null
if ((rawKind !== null && !["auto", "manual"].includes(rawKind)) || (rawTab !== null && !["history", "settings"].includes(rawTab))) error = "В ссылке указан неизвестный тип или раздел резервных копий."
if (search.length > 200) error = "Поиск в ссылке слишком длинный."
let query = emptyBackupQuery()
const raw = params.get("filters")
if (["q", "kind", "tab", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка."
if (raw !== null) {
try {
if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error()
const parsed: unknown = JSON.parse(raw)
if (!record(parsed) || parsed.v !== 1 || validateBackupQuery(parsed.query) !== "valid") throw new Error()
query = parsed.query as FilterQuery
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
}
return { search, kind, tab, query, error }
}
/** Update only owned keys; retain object context and parameters from other features. */
export function patchBackupsUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/backups" + (next.size ? "?" + next.toString() : "")
}
+38
View File
@@ -0,0 +1,38 @@
import type { FilterField } from "@/components/reui/filters/filters-types"
export const BGP_FILTER_FIELDS: FilterField[] = [
{
id: "state",
label: "Состояние",
type: "select",
options: [
{ value: "Unknown", label: "Неизвестно" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "Connect", label: "Connect" },
{ value: "OpenSent", label: "OpenSent" },
{ value: "OpenConfirm", label: "OpenConfirm" },
],
},
{
id: "type",
label: "Тип",
type: "select",
options: [
{ value: "Unknown", label: "Тип неизвестен" },
{ value: "eBGP", label: "eBGP" },
{ value: "iBGP", label: "iBGP" },
],
},
{
id: "afi",
label: "AFI",
type: "select",
options: [
{ value: "IPv4 Unicast", label: "IPv4 Unicast" },
{ value: "IPv6 Unicast", label: "IPv6 Unicast" },
{ value: "VPNv4 Unicast", label: "VPNv4 Unicast" },
],
},
]
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { emptyBgpQuery, encodeBgpQuery, patchBgpUrl, readBgpUrl, validateBgpQuery } from "./bgp-url-state"
import type { FilterQuery } from "../../components/reui/filters/filters-types"
const query = (): FilterQuery => ({ ...emptyBgpQuery(), combinator: "or", rules: [
{ id: "a", type: "rule", path: ["state"], operator: "is_any_of", value: ["Active", "Idle"], negated: true },
{ id: "nested", type: "group", combinator: "and", rules: [{ id: "b", type: "rule", path: ["type"], operator: "is", value: "Unknown" }] },
] })
test("URL roundtrip preserves groups, negation, context and unrelated keys", () => {
const href = patchBgpUrl(new URLSearchParams("bgp=2&keep=1"), { q: "Тест & /", type: "eBGP", filters: encodeBgpQuery(query()) })
const params = new URL(href, "http://test").searchParams
assert.deepEqual(readBgpUrl(params), { search: "Тест & /", type: "eBGP", state: "all", query: query(), error: null })
assert.equal(patchBgpUrl(params, { q: null, type: null, filters: null }), "/bgp?bgp=2&keep=1")
})
test("draft conditions never serialize as a completed filter", () => {
const draft = { ...emptyBgpQuery(), rules: [{ id: "draft", type: "rule" as const, path: ["type"], operator: "is", value: undefined }] }
assert.equal(validateBgpQuery(draft), "draft")
assert.throws(() => encodeBgpQuery(draft))
assert.ok(readBgpUrl(new URLSearchParams({ filters: JSON.stringify({ v: 1, query: draft }) })).error)
})
test("invalid URL variants are rejected instead of widening a selection", () => {
for (const filters of ["{", "null", JSON.stringify({ v: 2, query: query() }), " ".repeat(8193), JSON.stringify({ v: 1, query: { ...query(), rules: [{ id: "x", type: "rule", path: ["password"], operator: "is", value: "Active" }] } })]) {
assert.ok(readBgpUrl(new URLSearchParams({ filters })).error)
}
assert.ok(readBgpUrl(new URLSearchParams("type=unexpected")).error)
assert.ok(readBgpUrl(new URLSearchParams("type=eBGP&type=iBGP")).error)
})
test("limits, IDs, operator arity and values are validated", () => {
const rule = { id: "r", type: "rule", path: ["state"], operator: "is", value: "Active" }
for (const rules of [[rule, rule], [{ ...rule, operator: "unknown" }], [{ ...rule, value: "Unexpected" }], [{ ...rule, operator: "is_any_of" }], [{ ...rule, operator: "empty" }], Array.from({ length: 51 }, (_, i) => ({ ...rule, id: String(i) }))]) {
assert.equal(validateBgpQuery({ ...emptyBgpQuery(), rules }), "invalid")
}
let nested: FilterQuery = emptyBgpQuery()
for (let i = 0; i < 6; i++) nested = { id: "group" + i, type: "group", combinator: "and", rules: [nested] }
assert.equal(validateBgpQuery(nested), "invalid")
assert.equal(encodeBgpQuery(emptyBgpQuery()), null)
})
+75
View File
@@ -0,0 +1,75 @@
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { BGP_FILTER_FIELDS } from "./bgp-filter-fields"
export type BgpTypeFilter = "all" | "eBGP" | "iBGP" | "Unknown"
export const emptyBgpQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
const types = ["eBGP", "iBGP", "Unknown"]
const states = ["Established", "Active", "Idle", "Connect", "OpenSent", "OpenConfirm", "Unknown"]
export type BgpStateFilter = "all" | "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm" | "Unknown"
const MAX_BYTES = 8192
const options = new Map(BGP_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))]))
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
export function validateBgpQuery(value: unknown): "valid" | "draft" | "invalid" {
const ids = new Set<string>()
let count = 0
let draft = false
function visit(node: unknown, depth: number): boolean {
if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
ids.add(node.id)
if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
const fieldId = node.path[0]
const allowed = options.get(fieldId)
const accepts = (value: string) => fieldId === "afi" ? value.trim().length > 0 && value.length <= 256 : Boolean(allowed?.has(value))
if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false
if (node.operator === "" || node.operator === undefined) { draft = true; return true }
if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined
if (!["is", "is_not", "is_any_of", "is_none_of"].includes(String(node.operator))) return false
if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true }
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && accepts(node.value)
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && accepts(item))
}
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
return draft ? "draft" : "valid"
}
export function encodeBgpQuery(query: FilterQuery): string | null {
if (validateBgpQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.")
if (query.rules.length === 0) return null
const encoded = JSON.stringify({ v: 1, query })
if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.")
return encoded
}
export function readBgpUrl(params: URLSearchParams) {
const search = params.get("q") ?? ""
const rawState = params.get("state")
const state: BgpStateFilter = states.includes(rawState ?? "") ? rawState as BgpStateFilter : "all"
const rawType = params.get("type")
const type: BgpTypeFilter = types.includes(rawType ?? "") ? rawType as BgpTypeFilter : "all"
let error: string | null = rawType !== null && !types.includes(rawType) ? "В ссылке указан неизвестный тип BGP." : null
if (rawState !== null && !states.includes(rawState)) error = "В ссылке указано неизвестное состояние BGP."
let query = emptyBgpQuery()
const raw = params.get("filters")
if (["q", "type", "state", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка."
if (raw !== null) {
try {
if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error()
const parsed: unknown = JSON.parse(raw)
if (!record(parsed) || parsed.v !== 1 || validateBgpQuery(parsed.query) !== "valid") throw new Error()
query = parsed.query as FilterQuery
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
}
return { search, type, state, query, error }
}
/** Update only owned keys; retain object context and parameters from other features. */
export function patchBgpUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/bgp" + (next.size ? "?" + next.toString() : "")
}
+43
View File
@@ -0,0 +1,43 @@
import type { FilterLabels } from "@/components/reui/filters/filters-types"
export const FILTER_LABELS_RU: FilterLabels = {
addFilter: "Фильтры", advancedFilter: "Расширенные фильтры", showRecords: "Показать записи",
builderEmpty: "Фильтров пока нет", builderEmptyHint: "Добавьте условие, чтобы уточнить список.",
addCondition: "Добавить условие", addConditionGroup: "Добавить группу", addToGroup: "Добавить условие в группу",
removeGroup: "Удалить группу", wrapInGroup: "Объединить в группу", ungroup: "Разгруппировать",
moveToTopLevel: "На верхний уровень", moveToGroup: n => `В группу ${n}`,
reorder: "Изменить порядок", reorderHint: "Alt + стрелка вверх или вниз — изменить порядок",
groupAll: "Выполнены все условия", groupAny: "Выполнено любое условие", groupPlaceholder: "Перетащите условия сюда",
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
groupAnnouncement: added => added ? "Группа добавлена" : "Группа удалена",
reorderAnnouncement: (label, position, total) => `${label}: позиция ${position} из ${total}`,
moveAnnouncement: (label, destination, position, total) => `${label}: ${destination}, позиция ${position} из ${total}`,
clearAll: "Сбросить фильтры", groupMenu: "Действия с группой",
searchFields: "Поиск поля…", searchOperators: "Поиск условия…", searchOptions: "Поиск значения…",
back: "Назад", clear: "Очистить", apply: "Применить", discard: "Отменить изменения",
empty: "Ничего не найдено", loading: "Загрузка…", loadingMore: "Загрузка…", loadMore: "Загрузить ещё",
error: "Не удалось загрузить", retry: "Повторить", where: "Где", and: "И", or: "ИЛИ",
combinator: "Изменить связь условий", combinatorLabel: word => `${word}, изменить связь условий`,
duplicate: "Дублировать", negate: "Инвертировать", convertToAdvanced: "Расширенный редактор", remove: "Удалить",
chipMenu: field => `Действия с фильтром «${field}»`, filtersLabel: "Фильтры", filterLabel: condition => condition,
readOnly: "Фильтры доступны только для чтения.", pathSeparator: " > ", valuePlaceholder: "Введите значение…",
selectPlaceholder: "Выберите…", noValue: "нет значения", selectCondition: "Выберите условие",
incomplete: "незавершённый фильтр", branchAffordance: "открывает список",
exclusiveHint: "нельзя сочетать с другими значениями",
exclusiveAnnouncement: (label, cleared) => `Выбрано ${label}. Снято других значений: ${cleared}.`,
itemCount: count => `Элементов: ${count}`, fieldsLabel: "Поля",
resultsAnnouncement: count => `Найдено: ${count}`, actionsLabel: "Действия",
stepAnnouncement: (step, label) => `${step === "field" ? "Выберите поле" : step === "operator" ? "Выберите условие" : "Введите значение"}. ${label}`,
countAnnouncement: count => `Применено фильтров: ${count}`, valueCount: count => `Выбрано: ${count}`,
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`, valueRange: (from, to) => `${from} — ${to}`,
rangeFrom: field => `${field}: от`, rangeTo: field => `${field}: до`, rangeSeparator: "до",
negated: operator => `не (${operator})`, issueOperator: "Выберите условие", issueValue: "Введите значение",
issueRange: "Укажите обе границы", issueRangeOrder: "Конец диапазона меньше начала",
issueEmptyGroup: "В группе пока нет условий", issueSummary: count => `Требуют внимания: ${count}`,
}
export const FILTER_OPERATOR_LABELS_RU = {
is: "равно", is_not: "не равно", is_any_of: "любой из", is_none_of: "ни один из",
contains: "содержит", not_contains: "не содержит", empty: "не задано", not_empty: "задано",
}
+45
View File
@@ -0,0 +1,45 @@
import type { FilterField } from "@/components/reui/filters/filters-types"
import type { Server, ServerType } from "@/lib/data"
export const SERVER_FILTER_FIELDS: FilterField[] = [
{
id: "country",
label: "Страна",
type: "select",
searchable: true,
options: [
{ value: "RU", label: "RU" },
{ value: "DE", label: "DE" },
{ value: "NL", label: "NL" },
{ value: "SG", label: "SG" },
{ value: "FI", label: "FI" },
{ value: "US", label: "US" },
],
},
{
id: "status",
label: "Статус",
type: "select",
options: [
{ value: "online", label: "Online" },
{ value: "degraded", label: "Degraded" },
{ value: "offline", label: "Offline" },
],
},
{
id: "type",
label: "Тип",
type: "select",
options: [
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },
{ value: "home-router", label: "Home Router" },
],
},
]
export const SERVER_FILTER_ACCESSORS = {
country: (s: Server) => s.country ?? "",
status: (s: Server) => s.status,
type: (s: Server) => s.type as ServerType,
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { emptyServerQuery, encodeServerQuery, patchServerUrl, readServerUrl, validateServerQuery } from "./server-url-state"
import type { FilterQuery } from "../../components/reui/filters/filters-types"
const query = (): FilterQuery => ({ ...emptyServerQuery(), combinator: "or", rules: [
{ id: "a", type: "rule", path: ["country"], operator: "is_any_of", value: ["RU", "DE"], negated: true },
{ id: "nested", type: "group", combinator: "and", rules: [{ id: "b", type: "rule", path: ["status"], operator: "is", value: "offline" }] },
] })
test("URL roundtrip preserves groups, negation, context and unrelated keys", () => {
const href = patchServerUrl(new URLSearchParams("server=2&keep=1"), { q: "Тест & /", type: "exit-node", filters: encodeServerQuery(query()) })
const params = new URL(href, "http://test").searchParams
assert.deepEqual(readServerUrl(params), { search: "Тест & /", type: "exit-node", query: query(), error: null })
assert.equal(patchServerUrl(params, { q: null, type: null, filters: null }), "/servers?server=2&keep=1")
})
test("draft conditions never serialize as a completed filter", () => {
const draft = { ...emptyServerQuery(), rules: [{ id: "draft", type: "rule" as const, path: ["status"], operator: "is", value: undefined }] }
assert.equal(validateServerQuery(draft), "draft")
assert.throws(() => encodeServerQuery(draft))
assert.ok(readServerUrl(new URLSearchParams({ filters: JSON.stringify({ v: 1, query: draft }) })).error)
})
test("invalid URL variants are rejected instead of widening a selection", () => {
for (const filters of ["{", "null", JSON.stringify({ v: 2, query: query() }), " ".repeat(8193), JSON.stringify({ v: 1, query: { ...query(), rules: [{ id: "x", type: "rule", path: ["password"], operator: "is", value: "RU" }] } })]) {
assert.ok(readServerUrl(new URLSearchParams({ filters })).error)
}
assert.ok(readServerUrl(new URLSearchParams("type=unexpected")).error)
assert.ok(readServerUrl(new URLSearchParams("type=exit-node&type=jump-host")).error)
})
test("limits, IDs, operator arity and values are validated", () => {
const rule = { id: "r", type: "rule", path: ["country"], operator: "is", value: "RU" }
for (const rules of [[rule, rule], [{ ...rule, operator: "unknown" }], [{ ...rule, value: "XX" }], [{ ...rule, operator: "is_any_of" }], [{ ...rule, operator: "empty" }], Array.from({ length: 51 }, (_, i) => ({ ...rule, id: String(i) }))]) {
assert.equal(validateServerQuery({ ...emptyServerQuery(), rules }), "invalid")
}
let nested: FilterQuery = emptyServerQuery()
for (let i = 0; i < 6; i++) nested = { id: "group" + i, type: "group", combinator: "and", rules: [nested] }
assert.equal(validateServerQuery(nested), "invalid")
assert.equal(encodeServerQuery(emptyServerQuery()), null)
})
+68
View File
@@ -0,0 +1,68 @@
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { SERVER_FILTER_FIELDS } from "./server-filter-fields"
export type ServerTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
export const emptyServerQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
const types = ["jump-host", "exit-node", "home-router"]
const MAX_BYTES = 8192
const options = new Map(SERVER_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))]))
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
export function validateServerQuery(value: unknown): "valid" | "draft" | "invalid" {
const ids = new Set<string>()
let count = 0
let draft = false
function visit(node: unknown, depth: number): boolean {
if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
ids.add(node.id)
if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
const allowed = options.get(node.path[0])
if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false
if (node.operator === "" || node.operator === undefined) { draft = true; return true }
if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined
if (!["is", "is_not", "is_any_of", "is_none_of"].includes(String(node.operator))) return false
if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true }
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && allowed.has(node.value)
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
}
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
return draft ? "draft" : "valid"
}
export function encodeServerQuery(query: FilterQuery): string | null {
if (validateServerQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.")
if (query.rules.length === 0) return null
const encoded = JSON.stringify({ v: 1, query })
if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.")
return encoded
}
export function readServerUrl(params: URLSearchParams) {
const search = params.get("q") ?? ""
const rawType = params.get("type")
const type: ServerTypeFilter = types.includes(rawType ?? "") ? rawType as ServerTypeFilter : "all"
let error: string | null = rawType !== null && !types.includes(rawType) ? "В ссылке указан неизвестный тип сервера." : null
let query = emptyServerQuery()
const raw = params.get("filters")
if (["q", "type", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка."
if (raw !== null) {
try {
if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error()
const parsed: unknown = JSON.parse(raw)
if (!record(parsed) || parsed.v !== 1 || validateServerQuery(parsed.query) !== "valid") throw new Error()
query = parsed.query as FilterQuery
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
}
return { search, type, query, error }
}
/** Update only owned keys; retain object context and parameters from other features. */
export function patchServerUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/servers" + (next.size ? "?" + next.toString() : "")
}
@@ -0,0 +1,52 @@
import type { FilterField } from "@/components/reui/filters/filters-types"
export const STATISTICS_FILTER_FIELDS: FilterField[] = [
{
id: "country",
label: "Страна",
type: "text",
placeholder: "ISO, напр. US",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
id: "service",
label: "Сервис",
type: "text",
placeholder: "https, dns…",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
id: "asn",
label: "ASN",
type: "text",
placeholder: "номер ASN",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
id: "serverId",
label: "Сервер",
type: "text",
placeholder: "id сервера",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
id: "userId",
label: "Пользователь",
type: "text",
placeholder: "id пользователя",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
{
id: "iface",
label: "Интерфейс",
type: "text",
placeholder: "ether1",
defaultOperator: "is",
operators: [{ value: "is", label: "равно" }],
},
]
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
import { filterQueryToSlices, slicesToFilterQuery } from "./statistics-filter-query"
const rule = (field: string, value?: unknown, operator = "is") => createFilterRule({ id: field, path: [field], operator, value })
test("URL slices round-trip without dropping dimensions", () => {
const slices = { country: "RU", service: "https", asn: "64512", serverId: "1", userId: "user-1", iface: "ether1" }
assert.deepEqual(filterQueryToSlices(slicesToFilterQuery(slices)), { status: "valid", slices })
assert.deepEqual(filterQueryToSlices(createFilterQuery()), { status: "valid", slices: {} })
})
test("nested AND preserves all dimensions and normalizes values", () => {
const query = createFilterQuery([rule("country", " ru "), createFilterQuery([rule("asn", "064512"), rule("service", " dns ")])])
assert.deepEqual(filterQueryToSlices(query), { status: "valid", slices: { country: "RU", asn: "64512", service: "dns" } })
})
test("unfinished edits never publish partial slices", () => {
for (const value of [undefined, "", " "]) {
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "RU"), rule("service", value)])), { status: "draft" })
}
})
test("OR, negation, unsupported operators and conflicting equalities are rejected", () => {
for (const query of [
createFilterQuery([rule("country", "RU"), rule("country", "DE")], "or"),
createFilterQuery([{ ...rule("country", "RU"), negated: true }]),
createFilterQuery([rule("service", "http", "contains")]),
createFilterQuery([rule("country", "RU"), rule("country", "DE")]),
createFilterQuery([rule("country", ["RU", "DE"])]),
createFilterQuery([rule("unknown", "value")]),
]) assert.equal(filterQueryToSlices(query).status, "invalid")
})
test("invalid values are rejected instead of silently rewritten", () => {
for (const [key, value] of [["country", "RUS"], ["asn", "AS64512"], ["serverId", "-1"], ["serverId", "1.2"], ["asn", "9007199254740992"]]) {
assert.equal(filterQueryToSlices(createFilterQuery([rule(key, value)])).status, "invalid")
}
assert.deepEqual(filterQueryToSlices(createFilterQuery([rule("country", "ru"), rule("country", "RU")])), { status: "valid", slices: { country: "RU" } })
})
@@ -0,0 +1,71 @@
import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query"
import type { FilterNode, FilterQuery } from "@/components/reui/filters/filters-types"
export const STATISTICS_SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
export type StatisticsSlices = Partial<Record<typeof STATISTICS_SLICE_KEYS[number], string>>
export type StatisticsFilterResult =
| { status: "valid"; slices: StatisticsSlices }
| { status: "draft" }
| { status: "invalid"; error: string }
export function slicesToFilterQuery(slices: StatisticsSlices): FilterQuery {
return createFilterQuery(STATISTICS_SLICE_KEYS.flatMap(key => slices[key]
? [createFilterRule({ id: key, path: [key], operator: "is", value: slices[key] })] : []))
}
/** The statistics API accepts a conjunction of scalar equalities only.
* Never publish a partial draft or flatten OR into an unrelated API request. */
export function filterQueryToSlices(query: FilterQuery): StatisticsFilterResult {
const slices: StatisticsSlices = {}
let draft = false
let error: string | undefined
function visit(node: FilterNode) {
if (node.type === "group") {
if (node.combinator !== "and") {
error = "Статистика поддерживает только условия, объединённые «И»."
return
}
node.rules.forEach(visit)
return
}
const key = node.path[0] as keyof StatisticsSlices
if (node.path.length !== 1 || !STATISTICS_SLICE_KEYS.includes(key)) {
error = "Неизвестное поле среза статистики."
return
}
if (node.negated || (node.operator && node.operator !== "is")) {
error = "Для срезов статистики доступно только условие «равно»."
return
}
if (!node.operator || node.value === undefined || node.value === "") {
draft = true
return
}
if (typeof node.value !== "string") {
error = "Укажите одно текстовое значение среза."
return
}
let value = node.value.trim()
if (!value) { draft = true; return }
if (key === "country") {
if (!/^[a-z]{2}$/i.test(value)) { error = "Укажите двухбуквенный код страны."; return }
value = value.toUpperCase()
}
if (key === "asn" || key === "serverId") {
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) {
error = "ASN и идентификатор сервера должны быть положительными целыми числами."
return
}
value = String(Number(value))
}
if (slices[key] !== undefined && slices[key] !== value) {
error = "Для одного поля нельзя задать два разных значения среза."
return
}
slices[key] = value
}
visit(query)
if (error) return { status: "invalid", error }
if (draft) return { status: "draft" }
return { status: "valid", slices }
}
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readUptimeUrl, patchUptimeUrl } from "./uptime-url-state"
test("вкладка и фильтры Ping восстанавливаются из ссылки", () => {
const state = readUptimeUrl(new URLSearchParams({ tab: "speed", q: "узел", status: "down", servers: '["1","x/2"]' }))
assert.deepEqual(state, { tab: "speed", search: "узел", status: "down", servers: ["1", "x/2"], error: null })
})
test("некорректные и повторяющиеся параметры дают ошибку", () => {
for (const raw of ['tab=unknown', 'status=online', 'q=a&q=b', 'servers={}', 'servers=[1]', 'servers=["1","1"]', 'servers=[""]']) {
assert.ok(readUptimeUrl(new URLSearchParams(raw)).error, raw)
}
})
test("сброс оставляет пробу, вкладку и чужие параметры", () => {
const result = patchUptimeUrl(new URLSearchParams('probe=p%2F2&keep=1&tab=speed&q=a&status=down&servers=%5B%221%22%5D'), { q: null, status: null, servers: null })
assert.equal(result, '/uptime?probe=p%2F2&keep=1&tab=speed')
})
test("фильтры Resources независимы от Ping и валидируются", async () => {
const { readResourceUrl } = await import("./uptime-url-state")
const params = new URLSearchParams('tab=resources&q=ping&status=down&rq=CHR&rtype=exit-node')
assert.deepEqual(readResourceUrl(params), { search: 'CHR', type: 'exit-node', error: null })
assert.equal(readUptimeUrl(params).search, 'ping')
for (const raw of ['rtype=unknown', 'rtype=exit-node&rtype=jump-host', 'rq=a&rq=b']) assert.ok(readResourceUrl(new URLSearchParams(raw)).error)
assert.equal(patchUptimeUrl(params, { rq: null, rtype: null }), '/uptime?tab=resources&q=ping&status=down')
})
+46
View File
@@ -0,0 +1,46 @@
import type { Dispatch, SetStateAction } from "react"
export type UptimeTab = "probes" | "resources" | "speed"
export type ProbeStatusFilter = "all" | "up" | "warn" | "down" | "unknown"
export function readUptimeUrl(params: URLSearchParams) {
const rawTab = params.get("tab")
const rawStatus = params.get("status")
const search = params.get("q") ?? ""
const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes"
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" || rawStatus === "unknown" ? rawStatus : "all"
let error: string | null = null
let servers: string[] = []
if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) ||
(rawStatus !== null && !["up", "warn", "down", "unknown"].includes(rawStatus)) ||
["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга."
const rawServers = params.get("servers")
if (rawServers !== null) {
try {
if (rawServers.length > 8192) throw new Error()
const value: unknown = JSON.parse(rawServers)
if (!Array.isArray(value) || value.length > 50 || !value.every(id => typeof id === "string" && id.length > 0 && id.length <= 128) || new Set(value).size !== value.length) throw new Error()
servers = value
} catch { error = "Не удалось прочитать выбранные серверы из ссылки." }
}
return { tab, search, status, servers, error }
}
export function patchUptimeUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/uptime" + (next.size ? "?" + next.toString() : "")
}
export type UrlSetter<T> = Dispatch<SetStateAction<T>>
export type ResourceTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
export function readResourceUrl(params: URLSearchParams) {
const raw = params.get("rtype")
const valid = raw === null || ["jump-host", "exit-node", "home-router"].includes(raw)
const error = !valid || ["rq", "rtype"].some(key => params.getAll(key).length > 1)
? "В ссылке указаны неверные или повторяющиеся фильтры ресурсов." : null
return { search: params.get("rq") ?? "", type: (valid && raw !== null ? raw : "all") as ResourceTypeFilter, error }
}
+35
View File
@@ -0,0 +1,35 @@
import type { DataGridI18nLabels } from "@/components/reui/data-grid/data-grid-i18n"
export const DATA_GRID_LABELS_RU: DataGridI18nLabels = {
sortAscending: "По возрастанию",
sortDescending: "По убыванию",
pinColumnStart: "Закрепить слева",
pinColumnEnd: "Закрепить справа",
moveColumnStart: "Переместить влево",
moveColumnEnd: "Переместить вправо",
columnsMenu: "Столбцы",
unpinColumn: title => `Открепить столбец ${title}`,
toggleColumns: "Настроить столбцы",
rowCreate: "Добавить строку",
pinRow: "Закрепить строку",
unpinRow: "Открепить строку",
selectRow: "Выбрать строку",
selectAll: "Выбрать все",
expandRow: "Раскрыть строку",
collapseRow: "Свернуть строку",
dragToReorder: "Перетащите для изменения порядка",
dragToReorderRow: "Перетащите для изменения порядка строк",
reorderingUnavailable: "Изменение порядка недоступно",
loading: "Загрузка…",
empty: "Нет данных",
allRowsLoaded: "Все записи загружены",
rowsPerPage: "Строк на странице",
paginationInfo: ({ from, to, count }) => `${from}–${to} из ${count}`,
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
goToPage: page => `Перейти на страницу ${page}`,
paginationEllipsis: "…",
filterSelectedCount: count => `Выбрано: ${count}`,
filterNoResults: "Ничего не найдено",
filterClear: "Очистить фильтры",
}
+145
View File
@@ -0,0 +1,145 @@
"use client"
import { createContext, useContext, useEffect, useState, useCallback, useMemo, useRef } from "react"
import {
configuredBackendUrl,
defaultDataSourceMode,
isBackendUrlLocked,
isMockDataSourceAvailable,
LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl,
} from "@/lib/backend-url"
import { getBackendHealth } from "@/shared/api/backend-health"
// ── types ─────────────────────────────────────────────────────────────────────
export type DataSourceMode = "mock" | "live"
/** Режим «Живые» в настройках: моковые данные не подставлять. */
export function isLiveDataSourceMode(mode: DataSourceMode): boolean {
return mode === "live"
}
interface DataSourceContextValue {
mode: DataSourceMode
setMode: (m: DataSourceMode) => void
backendUrl: string
setBackendUrl: (url: string) => void
backendUrlLocked: boolean
mockModeAvailable: boolean
/** localStorage для mode/backendUrl применён на клиенте (после первого paint). */
prefsHydrated: boolean
/** undefined = не проверялось, true = OK, false = недоступен */
backendStatus: boolean | undefined
backendChecking: boolean
checkBackend: () => Promise<void>
}
// ── context ───────────────────────────────────────────────────────────────────
const DataSourceContext = createContext<DataSourceContextValue | null>(null)
const LS_MODE = "routerlists:data-source"
const LS_BACKEND = "routerlists:backend-url"
function readStoredMode(): DataSourceMode {
if (!isMockDataSourceAvailable()) return "live"
if (typeof window === "undefined") return defaultDataSourceMode()
const stored = localStorage.getItem(LS_MODE) as DataSourceMode | null
if (stored === "live") return "live"
if (stored === "mock") return "mock"
return defaultDataSourceMode()
}
function initialBackendUrl(): string {
if (typeof window === "undefined") {
const cfg = configuredBackendUrl()
if (cfg.kind === "same-origin") return ""
if (cfg.kind === "fixed") return cfg.url
return LOCAL_DEFAULT_BACKEND_URL
}
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
}
function normalizeBackendUrl(url: string): string {
return url.trim().replace(/\/$/, "")
}
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
const [prefsHydrated, setPrefsHydrated] = useState(false)
const healthToken = useMemo(() => Symbol(backendUrl), [backendUrl])
const [health, setHealth] = useState<{ token: symbol; value: boolean | undefined; checking: boolean }>(() => ({ token: healthToken, value: undefined, checking: false }))
const backendStatus = health.token === healthToken ? health.value : undefined
const backendChecking = health.token === healthToken && health.checking
const healthSession = useRef<{token:symbol;active:boolean;revision:number;controller?:AbortController}>({token:healthToken,active:true,revision:0})
useEffect(() => {
const scope = {token:healthToken,active:true,revision:0,controller:undefined as AbortController | undefined}
healthSession.current = scope
return () => { scope.active = false; scope.controller?.abort() }
}, [healthToken])
const backendUrlLocked = isBackendUrlLocked()
const mockModeAvailable = isMockDataSourceAvailable()
useEffect(() => {
let active = true
queueMicrotask(() => {
if (!active) return
const storedMode = readStoredMode()
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
setModeState(storedMode)
setBackendUrlState(url)
setPrefsHydrated(true)
})
return () => { active = false }
}, [])
const setMode = useCallback((m: DataSourceMode) => {
if (!mockModeAvailable && m === "mock") return
setModeState(m)
localStorage.setItem(LS_MODE, m)
}, [mockModeAvailable])
const setBackendUrl = useCallback((url: string) => {
if (backendUrlLocked) return
const normalized = normalizeBackendUrl(url)
setBackendUrlState(normalized)
localStorage.setItem(LS_BACKEND, normalized)
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const scope = healthSession.current
if (!scope.active || scope.token !== healthToken) return
const revision = ++scope.revision
scope.controller?.abort()
const controller = new AbortController(); scope.controller = controller
const current = () => scope.active && scope.revision === revision && !controller.signal.aborted
setHealth({token:healthToken,value:undefined,checking:true})
try {
await getBackendHealth(backendUrl, controller.signal)
if (current()) setHealth({token:healthToken,value:true,checking:false})
} catch {
if (current()) setHealth({token:healthToken,value:false,checking:false})
}
}, [backendUrl, healthToken])
useEffect(() => {
if (!prefsHydrated || mode !== "live") return
queueMicrotask(() => { void checkBackend() })
}, [prefsHydrated, mode, checkBackend])
return (
<DataSourceContext.Provider
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, prefsHydrated, backendStatus, backendChecking, checkBackend }}
>
{children}
</DataSourceContext.Provider>
)
}
export function useDataSource(): DataSourceContextValue {
const ctx = useContext(DataSourceContext)
if (!ctx) throw new Error("useDataSource must be used inside <DataSourceProvider>")
return ctx
}
+854
View File
@@ -0,0 +1,854 @@
export type ServerStatus = "online" | "offline" | "degraded" | "unknown"
export type ServerType = "jump-host" | "exit-node" | "home-router"
export interface WanUplink {
id: string
name: string // "WAN1-RT"
isp: string // "Rostelecom"
iface: string // "ether1"
ip: string // external IP
maxDl: number // Mbps
maxUl: number // Mbps
}
// ─── WireGuard ───────────────────────────────────────────────────────────────
export interface WireGuardPeer {
id?: string
rosId?: string
publicKey: string
allowedIps: string[]
endpoint?: string // "1.2.3.4:13231"
latestHandshake?: string // "2 минуты назад"
transferRx?: number // bytes
transferTx?: number // bytes
persistentKeepalive?: number
persistent?: boolean
comment?: string
disabled?: boolean
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}
export interface WireGuardInterface {
id: string
rosId?: string
name: string // e.g. "wg-msk-spb"
listenPort: number // default 13231
mtu: number // 1420 default in ROS 7.x
publicKey?: string
privateKey?: string
address?: string
peers: WireGuardPeer[]
comment: string
enabled: boolean
status: "up" | "down"
}
export interface Server {
id: string
name: string
host: string // management IP / API address
model: string
os: string
site: string
country: string // ISO 3166-1 alpha-2, e.g. "RU", "DE"
asn: string
type: ServerType
enabled: boolean
status: ServerStatus
latency: number | null
sessions: number
comment?: string
// RouterOS 7.x extras
ipv6Address?: string // global or link-local
wireGuardIfaces?: WireGuardInterface[]
vrfNames?: string[] // VRF instances on this router
rpkiEnabled?: boolean
// home-router specific
wanUplinks?: WanUplink[]
lanSubnet?: string // e.g. "192.168.10.0/24"
// snapshot / poll data (как GET /api/servers — последний poll MikroTik)
uptime?: string
cpuLoad?: number
/** байты, поля RouterOS resource */
freeMemory?: number
totalMemory?: number
polledAt?: string
}
export interface FlowspecRule {
id: string
dstPrefix?: string
srcPrefix?: string
ipProto?: string
dstPort?: string
action: "rate-limit" | "discard" | "redirect"
rate?: number // Mbps, for rate-limit
comment?: string
}
export interface Filter {
id: string
name: string
domains: number
ips: number
asns: number
servers: string[]
enabled: boolean
updated: string
communities: string[]
gateway: string
// RouterOS 7.x extras
largeCommunities?: string[] // RFC 8092 format "AS:VALUE1:VALUE2"
rpkiValid?: boolean // filter only RPKI-valid routes
flowspecRules?: FlowspecRule[]
}
export interface Domain {
id: string
domain: string
resolvedIp: string
asn: string
purpose: string
filter: string
updated: string
enabled: boolean
}
export interface IpRange {
id: string
cidr: string
asn: string
country: string
purpose: string
filter: string
updated: string
enabled: boolean
}
export interface Asn {
id: string
asn: string
org: string
country: string
prefixes: number | null
filter: string
updated: string
enabled: boolean
}
export type FirewallAction =
| "accept" | "drop" | "reject" | "mark-routing" | "mark-conn"
| "masquerade" | "src-nat" | "dst-nat"
| "fasttrack-connection" | "nfqueue" | "passthrough" | "return"
| "add-src-to-address-list" | "add-dst-to-address-list"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
export interface FirewallRule {
id: string
chain: string
action: FirewallAction | string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
hits: number
family?: "ip" | "ip6" | "any"
table?: FirewallTable
serverId?: string
serverName?: string
rosId?: string
tlsHost?: string
connRateLimit?: string
layer7Proto?: string
nfqueueId?: number
log?: boolean
logPrefix?: string
}
export interface FirewallAddressListEntry {
id: string
list: string
address: string
comment: string
disabled: boolean
timeout?: string
family?: "ip" | "ip6"
serverId?: string
serverName?: string
rosId?: string
}
export interface Backup {
id: string
server: string
serverId?: string | null
filename: string
size: string
sizeBytes?: number
created: string
kind: "auto" | "manual"
notes: string
storage: "local" | "s3" | "both"
uploadError?: string
localFileStatus?: "available" | "missing" | "unknown" | "not-stored"
}
// ─── VXLAN ───────────────────────────────────────────────────────────────────
export interface VxlanTunnel {
id: string
name: string // "vxlan-overlay-1"
vni: number // 0–16777215
port: number // source port (default 0 = auto)
dstPort: number // destination port (default 8472)
serverId: string
vtepIp: string // VTEP address on the server
remoteVteps: string[] // remote VTEP IPs (unicast-flood)
l2mtu: number | null
mtu?: number // actual interface MTU; absent in legacy snapshots
arpProxy: boolean
arpMode?: "disabled" | "enabled" | "local-proxy-arp" | "proxy-arp" | "reply-only"
macLearning: boolean
comment: string
enabled: boolean
status: "up" | "down"
}
// ─── VRF ─────────────────────────────────────────────────────────────────────
export interface VrfInstance {
id: string
name: string // VRF name
serverId: string
interfaces: string[] // list of interfaces in this VRF
routingTable: string // associated routing table
comment: string
}
// ─── Container (RouterOS 7.4+) ───────────────────────────────────────────────
export interface RouterContainer {
id: string
rosId?: string
name: string
serverId: string
image: string // e.g. "nginx:alpine"
tag: string
status: "running" | "stopped" | "error"
envs: { key: string; value: string }[]
mounts: { dst: string; src?: string }[]
interfaces: string[] // veth interface names
cmd?: string
startOnBoot: boolean
comment: string
uptime?: string
cpu?: number // CPU % 0-100
memMb?: number // MB
}
// ─── Certificate ─────────────────────────────────────────────────────────────
export type CertUsage = "server" | "client" | "ca" | "crl-sign"
export type CertStatus = "valid" | "expired" | "revoked"
export interface RouterCertificate {
id: string
name: string
serverId: string
commonName: string
sans: string[]
issuedBy: string
validFrom: string
validUntil: string
daysLeft: number
keySize: number
usage: CertUsage[]
trusted: boolean
status: CertStatus
}
export interface PingProbe {
id: string
srcServerId: string // which server sends the ping
srcInterface?: string // optional interface name on source router
name: string
target: string
filter: string
rtt: number | null
loss: number
status: "up" | "warn" | "down"
series: number[]
enabled: boolean
/** Показывать в блоке «Активные пробы» на дашборде (звезда в мониторинге) */
showOnDashboard?: boolean
}
export interface SystemEvent {
id: string
sev: "destructive" | "warning" | "info"
title: string
message: string
when: string
}
function spark(n: number, base: number, range: number): number[] {
let s = base * 1000 + range
return Array.from({ length: n }, () => {
s = (s * 9301 + 49297) % 233280
const r = s / 233280
return Math.max(1, Math.round(base + (r - 0.5) * range * 2))
})
}
export const servers: Server[] = [
{
id: "srv1", name: "mt-msk-core-01", host: "10.0.0.1", model: "CCR2004-1G-12S+2XS", os: "7.20.1",
site: "MSK", country: "RU", asn: "AS65001", type: "jump-host", enabled: true, status: "online", latency: 12, sessions: 4,
ipv6Address: "2a01:4f8:1c1c:a1b2::1",
rpkiEnabled: true,
vrfNames: ["mgmt", "bypass"],
wireGuardIfaces: [
{
id: "wg1", name: "wg-msk-spb", listenPort: 13231, mtu: 1420, comment: "MSK → SPB overlay", enabled: true, status: "up",
peers: [
{ publicKey: "SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.2/32","192.168.20.0/24"], endpoint: "10.0.1.1:13231", latestHandshake: "2 мин назад", transferRx: 182_400_000, transferTx: 64_800_000, persistent: true },
],
},
{
id: "wg2", name: "wg-msk-fra", listenPort: 13232, mtu: 1420, comment: "MSK → FRA overlay", enabled: true, status: "up",
peers: [
{ publicKey: "FRAPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.6/32"], endpoint: "10.0.2.1:13231", latestHandshake: "1 мин назад", transferRx: 421_000_000, transferTx: 210_000_000, persistent: true },
],
},
],
},
{
id: "srv2", name: "mt-spb-edge-01", host: "10.0.1.1", model: "CCR2116-12G-4S+", os: "7.20.1",
site: "SPB", country: "RU", asn: "AS65002", type: "exit-node", enabled: true, status: "online", latency: 28, sessions: 6,
ipv6Address: "2a01:4f8:1c2c:b2c3::1",
rpkiEnabled: true,
wireGuardIfaces: [
{
id: "wg3", name: "wg-spb-msk", listenPort: 13231, mtu: 1420, comment: "SPB → MSK overlay", enabled: true, status: "up",
peers: [
{ publicKey: "MSKPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.1/32"], endpoint: "10.0.0.1:13231", latestHandshake: "2 мин назад", transferRx: 64_800_000, transferTx: 182_400_000, persistent: true },
],
},
],
},
{
id: "srv3", name: "mt-fra-edge-01", host: "10.0.2.1", model: "CCR2004-16G-2S+", os: "7.20.1",
site: "FRA", country: "DE", asn: "AS65003", type: "exit-node", enabled: true, status: "online", latency: 41, sessions: 3,
ipv6Address: "2a01:4f8:1c3c:c3d4::1",
rpkiEnabled: true,
wireGuardIfaces: [
{
id: "wg4", name: "wg-fra-msk", listenPort: 13231, mtu: 1420, comment: "FRA → MSK overlay", enabled: true, status: "up",
peers: [
{ publicKey: "MSKPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.1/32"], endpoint: "10.0.0.1:13232", latestHandshake: "1 мин назад", transferRx: 210_000_000, transferTx: 421_000_000, persistent: true },
],
},
],
},
{
id: "srv4", name: "mt-ams-edge-01", host: "10.0.3.1", model: "RB5009UG+S+IN", os: "7.19.3",
site: "AMS", country: "NL", asn: "AS65004", type: "exit-node", enabled: true, status: "degraded", latency: 88, sessions: 2,
},
{
id: "srv5", name: "mt-sgp-edge-01", host: "10.0.4.1", model: "CCR2004-1G-12S+2XS", os: "7.14.2",
site: "SGP", country: "SG", asn: "AS65005", type: "exit-node", enabled: false, status: "offline", latency: null, sessions: 0,
},
{
id: "srv6", name: "mt-ams-test-01", host: "10.0.3.2", model: "hEX S", os: "7.20.1",
site: "AMS", country: "NL", asn: "AS65006", type: "jump-host", enabled: true, status: "online", latency: 92, sessions: 1,
},
{
id: "srv7", name: "mt-msk-lab-01", host: "10.0.0.7", model: "RB5009UG+S+IN", os: "7.20.1",
site: "MSK", country: "RU", asn: "AS65007", type: "jump-host", enabled: true, status: "online", latency: 14, sessions: 2,
vrfNames: ["lab"],
},
{
id: "home1", name: "home-msk-01", host: "192.168.10.1", model: "hEX S (RB760iGS)", os: "7.20.1",
site: "MSK", country: "RU", asn: "", type: "home-router", enabled: true, status: "online", latency: 3, sessions: 0,
comment: "MSK офис — основной роутер",
lanSubnet: "192.168.10.0/24",
wanUplinks: [
{ id: "w1a", name: "WAN1-RT", isp: "Rostelecom", iface: "ether1", ip: "94.25.168.1", maxDl: 500, maxUl: 500 },
{ id: "w1b", name: "WAN2-BL", isp: "Beeline", iface: "ether2", ip: "85.174.12.1", maxDl: 100, maxUl: 100 },
],
},
{
id: "home2", name: "home-spb-01", host: "192.168.20.1", model: "RB5009UG+S+IN", os: "7.20.1",
site: "SPB", country: "RU", asn: "", type: "home-router", enabled: true, status: "online", latency: 5, sessions: 0,
comment: "SPB офис — основной роутер",
lanSubnet: "192.168.20.0/24",
wanUplinks: [
{ id: "w2a", name: "WAN1-MTS", isp: "МТС", iface: "ether1", ip: "178.214.4.1", maxDl: 300, maxUl: 300 },
{ id: "w2b", name: "WAN2-ERT", isp: "ЭР-Телеком", iface: "ether2", ip: "95.31.14.1", maxDl: 200, maxUl: 150 },
],
},
]
export const filters: Filter[] = [
{
id: "f1", name: "youtube-bypass", domains: 1842, ips: 4218, asns: 12,
servers: ["srv1","srv2","srv3","srv4"], enabled: true, updated: "12 мин назад",
communities: ["65001:100","65001:200"], gateway: "10.0.0.1",
largeCommunities: ["65001:100:1","65001:100:2"],
rpkiValid: true,
},
{
id: "f2", name: "streaming-eu", domains: 612, ips: 2104, asns: 8,
servers: ["srv2","srv3","srv4"], enabled: true, updated: "2 ч назад",
communities: ["65001:200"], gateway: "10.0.1.1",
largeCommunities: ["65001:200:1"],
rpkiValid: true,
},
{
id: "f3", name: "cdn-bypass", domains: 4218, ips: 18211, asns: 22,
servers: ["srv1","srv2","srv3","srv4","srv6"], enabled: true, updated: "1 ч назад",
communities: ["65001:300","65002:100"], gateway: "10.0.0.1",
flowspecRules: [
{ id: "fs1", dstPrefix: "104.16.0.0/12", ipProto: "tcp", dstPort: "443", action: "rate-limit", rate: 100, comment: "CF rate-limit" },
],
},
{ id: "f4", name: "social-block-school", domains: 84, ips: 412, asns: 4, servers: ["srv1","srv7"], enabled: false, updated: "3 дн назад", communities: ["65007:999"], gateway: "" },
{ id: "f5", name: "office-direct", domains: 142, ips: 884, asns: 6, servers: ["srv1","srv2","srv3","srv4","srv6","srv7"], enabled: true, updated: "4 ч назад", communities: ["65001:400"], gateway: "10.0.0.1" },
{ id: "f6", name: "gaming-low-latency", domains: 88, ips: 1212, asns: 14, servers: ["srv1","srv2","srv3","srv7"], enabled: true, updated: "24 мин назад", communities: ["65001:500","65002:200"], gateway: "10.0.2.1" },
]
export const domains: Domain[] = [
{ id: "d1", domain: "youtube.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "142.250.74.110", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d2", domain: "googlevideo.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "142.250.74.142", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d3", domain: "ytimg.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "172.217.16.46", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d4", domain: "netflix.com", purpose: "streaming", filter: "streaming-eu", resolvedIp: "54.246.79.9", asn: "AS2906", updated: "2 ч", enabled: true },
{ id: "d5", domain: "nflxvideo.net", purpose: "streaming", filter: "streaming-eu", resolvedIp: "45.57.32.18", asn: "AS2906", updated: "2 ч", enabled: true },
{ id: "d6", domain: "cloudflare.com", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "104.16.132.229", asn: "AS13335", updated: "1 ч", enabled: true },
{ id: "d7", domain: "akamaized.net", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "23.46.18.94", asn: "AS20940", updated: "1 ч", enabled: true },
{ id: "d8", domain: "fastly.net", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "151.101.1.95", asn: "AS54113", updated: "1 ч", enabled: true },
{ id: "d9", domain: "twitch.tv", purpose: "streaming", filter: "streaming-eu", resolvedIp: "52.223.241.72", asn: "AS16509", updated: "2 ч", enabled: false },
{ id: "d10", domain: "discord.com", purpose: "voip", filter: "gaming-low-latency", resolvedIp: "162.159.135.232", asn: "AS13335", updated: "24 мин", enabled: true },
{ id: "d11", domain: "steam.com", purpose: "gaming", filter: "gaming-low-latency", resolvedIp: "23.45.139.40", asn: "AS20940", updated: "24 мин", enabled: true },
{ id: "d12", domain: "epicgames.com", purpose: "gaming", filter: "gaming-low-latency", resolvedIp: "34.205.247.51", asn: "AS16509", updated: "24 мин", enabled: true },
]
export const ipRanges: IpRange[] = [
{ id: "i1", cidr: "142.250.0.0/15", purpose: "streaming", filter: "youtube-bypass", asn: "AS15169", country: "US", updated: "12 мин", enabled: true },
{ id: "i2", cidr: "172.217.0.0/16", purpose: "streaming", filter: "youtube-bypass", asn: "AS15169", country: "US", updated: "12 мин", enabled: true },
{ id: "i3", cidr: "104.16.0.0/12", purpose: "cdn", filter: "cdn-bypass", asn: "AS13335", country: "US", updated: "1 ч", enabled: true },
{ id: "i4", cidr: "23.32.0.0/11", purpose: "cdn", filter: "cdn-bypass", asn: "AS20940", country: "US", updated: "1 ч", enabled: true },
{ id: "i5", cidr: "151.101.0.0/16", purpose: "cdn", filter: "cdn-bypass", asn: "AS54113", country: "US", updated: "1 ч", enabled: true },
{ id: "i6", cidr: "54.246.0.0/16", purpose: "streaming", filter: "streaming-eu", asn: "AS2906", country: "IE", updated: "2 ч", enabled: true },
{ id: "i7", cidr: "45.57.0.0/16", purpose: "streaming", filter: "streaming-eu", asn: "AS2906", country: "NL", updated: "2 ч", enabled: true },
{ id: "i8", cidr: "162.159.128.0/19", purpose: "voip", filter: "gaming-low-latency", asn: "AS13335", country: "US", updated: "24 мин", enabled: true },
{ id: "i9", cidr: "52.223.240.0/20", purpose: "streaming", filter: "streaming-eu", asn: "AS16509", country: "IE", updated: "2 ч", enabled: false },
]
export const asns: Asn[] = [
{ id: "a1", asn: "AS15169", org: "GOOGLE", country: "US", prefixes: 842, filter: "youtube-bypass", updated: "12 мин", enabled: true },
{ id: "a2", asn: "AS13335", org: "CLOUDFLARENET", country: "US", prefixes: 1284, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a3", asn: "AS16509", org: "AMAZON-02", country: "US", prefixes: 4128, filter: "streaming-eu", updated: "2 ч", enabled: true },
{ id: "a4", asn: "AS20940", org: "AKAMAI-ASN1", country: "US", prefixes: 614, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a5", asn: "AS54113", org: "FASTLY", country: "US", prefixes: 122, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a6", asn: "AS2906", org: "AS-SSI (Netflix)", country: "US", prefixes: 88, filter: "streaming-eu", updated: "2 ч", enabled: true },
{ id: "a7", asn: "AS32934", org: "FACEBOOK", country: "US", prefixes: 412, filter: "social-block-school",updated: "3 дн", enabled: false },
{ id: "a8", asn: "AS8075", org: "MICROSOFT-CORP", country: "US", prefixes: 882, filter: "office-direct", updated: "4 ч", enabled: true },
]
export const firewallRules: FirewallRule[] = [
{ id: "fw1", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 },
{ id: "fw2", table: "mangle", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 },
{ id: "fw3", table: "nat", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 },
{ id: "fw4", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 },
{ id: "fw5", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*3", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 },
{ id: "fw6", table: "filter", family: "ip", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*4", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 },
{ id: "fw7", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*1", chain: "input", action: "accept", proto: "icmpv6", src: "fe80::/10", dst: "—", port: "—", iface: "—", comment: "IPv6 link-local ICMPv6", enabled: true, hits: 1204 },
{ id: "fw8", table: "filter", family: "ip6", serverId: "srv1", serverName: "mt-msk-core-01", rosId: "*2", chain: "forward", action: "accept", proto: "tcp", src: "2a01:4f8::/32", dst: "::/0", port: "443", iface: "wan-bgp", comment: "IPv6 HTTPS forward", enabled: true, hits: 88421 },
]
export const backups: Backup[] = [
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением", storage: "local" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "", storage: "s3" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP", storage: "both" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
]
export const pingProbes: PingProbe[] = [
// srv1 — mt-msk-core-01 (JH, MSK) → основной мониторинг
{ id: "p1", srcServerId: "srv1", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 18, loss: 0, status: "up", series: spark(40, 18, 4), enabled: true },
{ id: "p2", srcServerId: "srv1", name: "cloudflare.com", target: "104.16.132.229", filter: "cdn-bypass", rtt: 4, loss: 0, status: "up", series: spark(40, 4, 1), enabled: true },
{ id: "p3", srcServerId: "srv1", name: "netflix-eu", target: "54.246.79.9", filter: "streaming-eu", rtt: 28, loss: 0, status: "up", series: spark(40, 28, 5), enabled: true },
{ id: "p4", srcServerId: "srv1", name: "fastly-edge", target: "151.101.1.95", filter: "cdn-bypass", rtt: 8, loss: 0, status: "up", series: spark(40, 8, 2), enabled: true },
// srv7 — mt-msk-lab-01 (JH, MSK) → лаборатория
{ id: "p5", srcServerId: "srv7", name: "discord-voip", target: "162.159.135.232", filter: "gaming-low-latency", rtt: 12, loss: 0, status: "up", series: spark(40, 12, 2), enabled: true },
{ id: "p6", srcServerId: "srv7", name: "steam-cdn", target: "23.45.139.40", filter: "gaming-low-latency", rtt: 38, loss: 1.2, status: "warn", series: spark(40, 38, 12), enabled: true },
{ id: "p7", srcServerId: "srv7", name: "akamai-edge", target: "23.46.18.94", filter: "cdn-bypass", rtt: 22, loss: 0, status: "up", series: spark(40, 22, 4), enabled: true },
// srv2 — mt-spb-edge-01 (EN, SPB) → исходящий трафик SPB
{ id: "p8", srcServerId: "srv2", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 22, loss: 0, status: "up", series: spark(40, 22, 5), enabled: true },
{ id: "p9", srcServerId: "srv2", name: "office-365", target: "104.47.0.22", filter: "office-direct", rtt: 31, loss: 0, status: "up", series: spark(40, 31, 6), enabled: false },
// srv3 — mt-fra-edge-01 (EN, FRA) → исходящий трафик Frankfurt
{ id: "p10", srcServerId: "srv3", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 12, loss: 0, status: "up", series: spark(40, 12, 3), enabled: true },
{ id: "p11", srcServerId: "srv3", name: "netflix-eu", target: "54.246.79.9", filter: "streaming-eu", rtt: 9, loss: 0, status: "up", series: spark(40, 9, 2), enabled: true },
// srv4 — mt-ams-edge-01 (EN, AMS, degraded) → деградировавший узел
{ id: "p12", srcServerId: "srv4", name: "cloudflare.com", target: "104.16.132.229", filter: "cdn-bypass", rtt: 88, loss: 3.4, status: "warn", series: spark(40, 88, 22), enabled: true },
{ id: "p13", srcServerId: "srv4", name: "sgp-probe", target: "139.180.22.4", filter: "—", rtt: null,loss: 100, status: "down", series: Array(40).fill(0), enabled: true },
]
export const systemEvents: SystemEvent[] = [
{ id: "e1", sev: "destructive", title: "BGP-сессия упала", message: "mt-sgp-edge-01 · пир 139.180.22.4 · простой 2ч", when: "2ч" },
{ id: "e2", sev: "destructive", title: "Ошибка синхронизации фильтра", message: "cdn-bypass → mt-ams-edge-01 · таймаут", when: "14м" },
{ id: "e3", sev: "warning", title: "Высокая задержка на AMS", message: "среднее 88мс · порог 60мс", when: "8м" },
{ id: "e4", sev: "warning", title: "Бэкап просрочен", message: "mt-msk-lab-01 · последний 5д назад", when: "1ч" },
{ id: "e5", sev: "info", title: "Обновление BGP завершено", message: "+412 префиксов · −188 отозвано", when: "12м" },
{ id: "e6", sev: "info", title: "Фильтр youtube-bypass обновлён", message: "+88 доменов · ↑ 24 сервера", when: "12м" },
]
export const dashLatency = {
msk: spark(60, 12, 4),
spb: spark(60, 28, 6),
fra: spark(60, 41, 9),
ams: spark(60, 88, 18),
}
export const traffic = {
rx: Array.from({ length: 60 }, (_, i) => Math.round(280 + Math.sin(i / 6) * 60 + ((i * 7919) % 31))),
tx: Array.from({ length: 60 }, (_, i) => Math.round(220 + Math.sin(i / 5) * 50 + ((i * 6271) % 21))),
}
// ─── GRE ────────────────────────────────────────────────────────────────────
export type GreStatus = "up" | "down" | "degraded"
export type IpsecEncAlg = "aes-128" | "aes-192" | "aes-256"
export type IpsecAuthAlg = "sha1" | "sha256" | "sha512"
export type IpsecDhGroup = "modp1024" | "modp2048" | "modp4096" | "ecp256" | "ecp384" | "ecp521"
export type IkeVersion = "ikev1" | "ikev2"
export type DscpMode = "inherit" | number
export interface GreIpsec {
secret: string // ipsec-secret → auto-creates peer+policy+proposal
/** Live CHR отдаёт только secret; proposal-поля есть у моков / формы */
encAlg?: IpsecEncAlg
authAlg?: IpsecAuthAlg
dhGroup?: IpsecDhGroup
ikeVersion?: IkeVersion
lifetime?: string
pfs?: boolean
}
export interface GreTunnel {
id: string
name: string // interface name, e.g. gre-msk-spb
serverId: string // which MikroTik runs this tunnel
localAddress: string // local GRE endpoint ("0.0.0.0" = auto)
remoteAddress: string // remote GRE endpoint (required)
localInnerIp: string // /ip address on this side, e.g. 10.200.0.1/30
remoteInnerIp: string // /ip address on remote side, e.g. 10.200.0.2/30
poolId: string // which IP pool the inner IPs come from
ipsec: GreIpsec | null // null = no IPsec (plain GRE)
mtu: number // default 1476
keepaliveInterval: number // seconds, 0 = disabled
keepaliveRetries: number
dscp: DscpMode // "inherit" or 0-63
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: GreStatus
}
export interface GrePool {
id: string
name: string
cidr: string
allocated: number
total: number
comment: string
}
const defaultIpsec = (secret: string, ikeVersion: IkeVersion = "ikev2"): GreIpsec => ({
secret,
encAlg: "aes-256",
authAlg: "sha256",
dhGroup: "modp2048",
ikeVersion,
lifetime: "1d 00:00:00",
pfs: true,
})
export const grePools: GrePool[] = [
{ id: "gp1", name: "pool-gre-core", cidr: "10.200.0.0/24", allocated: 16, total: 64, comment: "Ядровые межузловые туннели" },
{ id: "gp2", name: "pool-gre-mgmt", cidr: "10.201.0.0/24", allocated: 8, total: 64, comment: "Управляющие туннели (lab, test)" },
]
export const greTunnels: GreTunnel[] = [
{
id: "gre1", name: "gre-msk-spb", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.1.1",
localInnerIp: "10.200.0.1/30", remoteInnerIp: "10.200.0.2/30", poolId: "gp1",
ipsec: defaultIpsec("xK9#mQ2$vLp8nR4"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → SPB core interconnect", enabled: true, status: "up",
},
{
id: "gre2", name: "gre-msk-fra", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.2.1",
localInnerIp: "10.200.0.5/30", remoteInnerIp: "10.200.0.6/30", poolId: "gp1",
ipsec: defaultIpsec("pW3@jT7!hD5kYe1"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → FRA core interconnect", enabled: true, status: "up",
},
{
id: "gre3", name: "gre-msk-ams", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.3.1",
localInnerIp: "10.200.0.9/30", remoteInnerIp: "10.200.0.10/30", poolId: "gp1",
ipsec: defaultIpsec("rN6%bF2*cM8sZa0"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: false,
comment: "MSK → AMS core interconnect", enabled: true, status: "degraded",
},
{
id: "gre4", name: "gre-msk-sgp", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.4.1",
localInnerIp: "10.200.0.13/30", remoteInnerIp: "10.200.0.14/30", poolId: "gp1",
ipsec: defaultIpsec("qA4^eU9#wG1oXi6"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → SGP core interconnect", enabled: false, status: "down",
},
{
id: "gre5", name: "gre-lab-core", serverId: "srv7",
localAddress: "10.0.0.7", remoteAddress: "10.0.0.1",
localInnerIp: "10.201.0.1/30", remoteInnerIp: "10.201.0.2/30", poolId: "gp2",
ipsec: null,
mtu: 1500, keepaliveInterval: 0, keepaliveRetries: 0,
dscp: "inherit", clampTcpMss: false, allowFastPath: true,
comment: "Lab → core (internal, no IPsec)", enabled: true, status: "up",
},
{
id: "gre6", name: "gre-ams-test", serverId: "srv6",
localAddress: "10.0.3.2", remoteAddress: "10.0.3.1",
localInnerIp: "10.201.0.5/30", remoteInnerIp: "10.201.0.6/30", poolId: "gp2",
ipsec: null,
mtu: 1476, keepaliveInterval: 5, keepaliveRetries: 5,
dscp: 0, clampTcpMss: true, allowFastPath: true,
comment: "AMS test → AMS edge", enabled: true, status: "up",
},
]
// ─── Filter Rules (Community → Gateway per server) ────────────────────────────
export interface FilterRule {
id: string
community: string // BGP community, e.g. "65001:100"
communityName?: string // optional human-readable name
action?: "route" | "blackhole" // default "route"; "blackhole" → null-route (RTBH)
gateway: string // next-hop IP for filter rule; empty for blackhole
/** GRE: id туннеля / имя интерфейса; рекурсивный маршрут: `rec:<id>` строки recursive_routes на сервере */
gatewayTunnelId: string
description: string
}
export interface ServerFilterRuleset {
serverId: string
rules: FilterRule[]
}
export const serverFilterRulesets: ServerFilterRuleset[] = [
// srv1 — mt-msk-core-01 (jump-host, MSK): gre1=SPB, gre2=FRA, gre3=AMS(deg), gre4=SGP(down)
{
serverId: "srv1",
rules: [
{ id: "r1", community: "65001:100", communityName: "youtube-bypass", gateway: "10.200.0.6", gatewayTunnelId: "gre2", description: "YouTube/Google Video → Frankfurt (IX)" },
{ id: "r2", community: "65001:200", communityName: "streaming-eu", gateway: "10.200.0.6", gatewayTunnelId: "gre2", description: "Netflix, Twitch EU → Frankfurt" },
{ id: "r3", community: "65001:300", communityName: "cdn-bypass", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "Cloudflare / Akamai / Fastly → SPB" },
{ id: "r4", community: "65002:100", communityName: "cdn-secondary", gateway: "10.200.0.10", gatewayTunnelId: "gre3", description: "CDN резерв → Amsterdam (degraded)" },
{ id: "r5", community: "65001:400", communityName: "office-direct", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "MS Office 365 / Teams → SPB direct" },
{ id: "r6", community: "65001:500", communityName: "gaming-ll", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "Discord / Steam / Epic — низкая задержка → SPB" },
{ id: "r10", community: "65535:666", communityName: "BLACKHOLE (RFC 7999)", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "RTBH — null-route входящих маршрутов" },
{ id: "r11", community: "65001:666", communityName: "blackhole-local", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "Локальная блокировка — сброс трафика" },
],
},
// srv7 — mt-msk-lab-01 (jump-host, MSK lab): gre5=core
{
serverId: "srv7",
rules: [
{ id: "r7", community: "65001:100", communityName: "youtube-bypass", gateway: "10.201.0.2", gatewayTunnelId: "gre5", description: "Lab: YouTube → core" },
{ id: "r8", community: "65007:999", communityName: "school-block", gateway: "10.201.0.2", gatewayTunnelId: "gre5", description: "Lab: школьная блокировка" },
],
},
// srv6 — mt-ams-test-01 (jump-host, AMS test): gre6=AMS edge
{
serverId: "srv6",
rules: [
{ id: "r9", community: "65001:300", communityName: "cdn-bypass", gateway: "10.201.0.6", gatewayTunnelId: "gre6", description: "AMS test: CDN → AMS edge" },
],
},
// Exit nodes — нет правил по умолчанию
{ serverId: "srv2", rules: [] },
{ serverId: "srv3", rules: [] },
{ serverId: "srv4", rules: [] },
{ serverId: "srv5", rules: [] },
]
// ─── VXLAN mock data ─────────────────────────────────────────────────────────
export const vxlanTunnels: VxlanTunnel[] = [
{
id: "vx1", name: "vxlan-overlay-msk", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv1", vtepIp: "10.0.0.1", remoteVteps: ["10.0.1.1","10.0.2.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "MSK overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx2", name: "vxlan-overlay-spb", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv2", vtepIp: "10.0.1.1", remoteVteps: ["10.0.0.1","10.0.2.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "SPB overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx3", name: "vxlan-overlay-fra", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv3", vtepIp: "10.0.2.1", remoteVteps: ["10.0.0.1","10.0.1.1"],
l2mtu: 1500, arpProxy: true, macLearning: false,
comment: "FRA overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx4", name: "vxlan-mgmt-lab", vni: 20001, port: 0, dstPort: 4789,
serverId: "srv7", vtepIp: "10.0.0.7", remoteVteps: ["10.0.0.1"],
l2mtu: 1500, arpProxy: false, macLearning: true,
comment: "Lab management VXLAN", enabled: true, status: "up",
},
{
id: "vx5", name: "vxlan-overlay-ams", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv4", vtepIp: "10.0.3.1", remoteVteps: ["10.0.0.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "AMS overlay (degraded)", enabled: true, status: "down",
},
]
// ─── Container mock data ─────────────────────────────────────────────────────
export const routerContainers: RouterContainer[] = [
{
id: "c1", name: "adguard-msk", serverId: "srv1",
image: "adguard/adguardhome", tag: "latest",
status: "running",
envs: [{ key: "AGH_CONFIG", value: "/opt/adguardhome/conf/AdGuardHome.yaml" }],
mounts: [{ dst: "/opt/adguardhome/conf", src: "/disk1/adguard" }, { dst: "/opt/adguardhome/work", src: "/disk1/adguard-work" }],
interfaces: ["veth-adguard"],
startOnBoot: true, comment: "AdGuard Home — DNS фильтрация",
uptime: "12д 4ч", cpu: 3, memMb: 64,
},
{
id: "c2", name: "prometheus-msk", serverId: "srv1",
image: "prom/prometheus", tag: "v2.52.0",
status: "running",
envs: [],
mounts: [{ dst: "/prometheus", src: "/disk1/prom-data" }],
interfaces: ["veth-prom"],
cmd: "--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.retention.time=30d",
startOnBoot: true, comment: "Prometheus метрики",
uptime: "5д 11ч", cpu: 8, memMb: 256,
},
{
id: "c3", name: "grafana-msk", serverId: "srv1",
image: "grafana/grafana", tag: "10.4.2",
status: "running",
envs: [{ key: "GF_SECURITY_ADMIN_PASSWORD", value: "*** (hidden)" }],
mounts: [{ dst: "/var/lib/grafana", src: "/disk1/grafana" }],
interfaces: ["veth-grafana"],
startOnBoot: true, comment: "Grafana — дашборды мониторинга",
uptime: "5д 11ч", cpu: 5, memMb: 128,
},
{
id: "c4", name: "wg-portal-spb", serverId: "srv2",
image: "wgportal/wg-portal", tag: "v1.3.1",
status: "running",
envs: [{ key: "WG_PORTAL_ADMIN_USER", value: "admin" }],
mounts: [{ dst: "/app/data", src: "/disk1/wgportal" }],
interfaces: ["veth-wgportal"],
startOnBoot: true, comment: "WireGuard Portal — управление клиентами",
uptime: "3д 7ч", cpu: 1, memMb: 48,
},
{
id: "c5", name: "unbound-fra", serverId: "srv3",
image: "mvance/unbound", tag: "1.19.3",
status: "stopped",
envs: [],
mounts: [],
interfaces: ["veth-unbound"],
startOnBoot: false, comment: "Unbound DNS резолвер (в резерве)",
cpu: 0, memMb: 0,
},
]
// ─── Certificate mock data ────────────────────────────────────────────────────
export const routerCertificates: RouterCertificate[] = [
{
id: "cert1", name: "mt-msk-core-01-server", serverId: "srv1",
commonName: "mt-msk-core-01.routerlists.io",
sans: ["mt-msk-core-01.routerlists.io","10.0.0.1"],
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
{
id: "cert2", name: "routerlists-internal-ca", serverId: "srv1",
commonName: "MikrotikManager Internal CA",
sans: [],
issuedBy: "MikrotikManager Internal CA (self-signed)", validFrom: "2023-06-01",
validUntil: "2033-06-01", daysLeft: 2589,
keySize: 4096, usage: ["ca","crl-sign"], trusted: true, status: "valid",
},
{
id: "cert3", name: "mt-spb-edge-01-server", serverId: "srv2",
commonName: "mt-spb-edge-01.routerlists.io",
sans: ["mt-spb-edge-01.routerlists.io","10.0.1.1"],
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
{
id: "cert4", name: "mt-fra-edge-01-server", serverId: "srv3",
commonName: "mt-fra-edge-01.routerlists.io",
sans: ["mt-fra-edge-01.routerlists.io","10.0.2.1"],
issuedBy: "Let's Encrypt", validFrom: "2026-01-10",
validUntil: "2026-04-10", daysLeft: -19,
keySize: 2048, usage: ["server"], trusted: true, status: "expired",
},
{
id: "cert5", name: "wg-client-admin", serverId: "srv1",
commonName: "admin.routerlists.io",
sans: ["admin.routerlists.io"],
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-06-01",
validUntil: "2026-06-01", daysLeft: 33,
keySize: 2048, usage: ["client"], trusted: true, status: "valid",
},
{
id: "cert6", name: "mt-ams-edge-01-server", serverId: "srv4",
commonName: "mt-ams-edge-01.routerlists.io",
sans: ["mt-ams-edge-01.routerlists.io"],
issuedBy: "MikrotikManager Internal CA", validFrom: "2024-04-01",
validUntil: "2026-05-15", daysLeft: 16,
keySize: 2048, usage: ["server"], trusted: false, status: "valid",
},
{
id: "cert7", name: "mt-msk-lab-01-server", serverId: "srv7",
commonName: "mt-msk-lab-01.routerlists.io",
sans: ["mt-msk-lab-01.routerlists.io","10.0.0.7"],
issuedBy: "MikrotikManager Internal CA", validFrom: "2025-09-01",
validUntil: "2026-09-01", daysLeft: 125,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
]
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { parseEvoBgpCatalog } from "./evobgp-catalog"
const empty = { fetchedAt: "2026-10-01T00:00:00Z", modules: [], domains: [], ipRanges: [], asns: [], communities: [] }
const domain = { id: "domain-1", domain: "example.test", resolvedIp: "—", asn: "—", purpose: "", filter: "", updated: "—", enabled: false }
test("known empty and false stay explicit without invented rows", () => {
assert.deepEqual(parseEvoBgpCatalog(empty), empty)
assert.equal(parseEvoBgpCatalog({ ...empty, domains: [domain] }).domains[0].enabled, false)
})
test("malformed or partial catalogue never reaches consumers as a snapshot", () => {
for (const raw of [{}, null, { ...empty, domains: undefined }, { ...empty, asns: {} }, { ...empty, fetchedAt: "bad" }, { ...empty, domains: [null] }, { ...empty, domains: [{ ...domain, enabled: undefined }] }, { ...empty, domains: [domain, domain] }]) assert.throws(() => parseEvoBgpCatalog(raw), /Каталог Evolution не подтверждён/)
})
test("ASN and community zero counts are preserved, invalid counts rejected", () => {
const asn = { id: "asn-1", asn: "AS1", org: "", country: "", prefixes: 0, filter: "", updated: "—", enabled: false }
assert.equal(parseEvoBgpCatalog({ ...empty, asns: [asn] }).asns[0].prefixes, 0)
assert.throws(() => parseEvoBgpCatalog({ ...empty, asns: [{ ...asn, prefixes: -1 }] }))
})
test("unknown counts stay nullable and differ from confirmed zero", () => {
const asn = { id: "asn-unknown", asn: "AS64512", org: "", country: "", prefixes: null, filter: "", updated: "—", enabled: true }
const community = { id: "c", value: "64512:1", name: "c", description: "", type: "custom", filterIds: [], serverCount: 0, prefixCount: null, action: "permit", enabled: true }
const result = parseEvoBgpCatalog({...empty, asns:[asn], communities:[community]})
assert.equal(result.asns[0].prefixes,null)
assert.equal(result.communities[0].prefixCount,null)
assert.equal(result.communities[0].serverCount,0)
for(const value of [undefined,-1,1.5,Number.MAX_SAFE_INTEGER+1]) {
assert.throws(()=>parseEvoBgpCatalog({...empty,asns:[{...asn,prefixes:value}]}))
assert.throws(()=>parseEvoBgpCatalog({...empty,communities:[{...community,prefixCount:value}]}))
}
})
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod"
import type { EvoBgpCatalogSnapshot } from "./evobgp-context"
const text = z.string()
const id = text.min(1)
const count = z.number().int().nonnegative().safe()
function unique<T extends z.ZodType<{ id: string }>>(row: T) {
return z.array(row).refine(rows => new Set(rows.map(item => item.id)).size === rows.length)
}
const base = { id, asn: text, purpose: text, filter: text, updated: text, enabled: z.boolean() }
const schema = z.object({
fetchedAt: text.refine(value => Number.isFinite(Date.parse(value))),
modules: unique(z.object({ id, name: text, type: text })),
domains: unique(z.object({ ...base, domain: text, resolvedIp: text })),
ipRanges: unique(z.object({ ...base, cidr: text, country: text })),
asns: unique(z.object({ id, asn: text, org: text, country: text, prefixes: count.nullable(), filter: text, updated: text, enabled: z.boolean() })),
communities: unique(z.object({ id, value: text, name: text, description: text, type: z.enum(["standard", "no-export", "no-advertise", "local-as", "custom"]), filterIds: z.array(text), serverCount: count, prefixCount: count.nullable(), action: z.enum(["permit", "deny", "local-pref", "metric"]), actionValue: z.number().finite().optional(), enabled: z.boolean() })),
})
export function parseEvoBgpCatalog(raw: unknown): EvoBgpCatalogSnapshot {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Каталог Evolution не подтверждён: получен некорректный ответ сервера")
return parsed.data
}
+108
View File
@@ -0,0 +1,108 @@
"use client"
import {createContext,useCallback,useContext,useEffect,useMemo,useRef,useState} from "react"
import {useDataSource} from "@/lib/data-source"
import {can} from "@/lib/auth"
import type {Domain,IpRange,Asn} from "@/lib/data"
import type {EvoBgpSettingsDto,EvoBgpSavePayload,EvoBgpTestDraft} from "@mmapp/contracts/evobgp"
export type {EvoBgpSettingsDto,EvoBgpSavePayload,EvoBgpTestDraft}
import {getEvoBgpSettings,putEvoBgpSettings,testEvoBgpConnection} from "@/shared/api/evobgp-settings"
import {parseEvoBgpCatalog} from "@/lib/evobgp-catalog"
import {requestJson} from "@/shared/api/http-client"
export interface EvoBgpCommunityRow {
id: string
value: string
name: string
description: string
type: "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
filterIds: string[]
serverCount: number
prefixCount: number | null
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number
enabled: boolean
}
export interface EvoBgpCatalogSnapshot {
fetchedAt: string
modules: { id: string; name: string; type: string }[]
domains: Domain[]
ipRanges: IpRange[]
asns: Asn[]
communities: EvoBgpCommunityRow[]
}
interface EvoBgpContextValue {
baseUrl:string;enabled:boolean;secretConfigured:boolean;settingsLoaded:boolean
settingsLoading:boolean;settingsError:string|null;settingsReceivedAt:string|null;sourceKey:string
snapshot:EvoBgpCatalogSnapshot|null;loading:boolean;error:string|null;catalogError:string|null;catalogReceivedAt:string|null;operationBusy:boolean
loadSettings:()=>Promise<void>;saveSettings:(patch:EvoBgpSavePayload)=>Promise<void>;refresh:()=>Promise<void>
testConnection:(draft?:EvoBgpTestDraft)=>Promise<{ok:boolean;message:string}>
}
const EvoBgpContext=createContext<EvoBgpContextValue|null>(null)
type State={token:symbol;sourceKey:string;settings:EvoBgpSettingsDto|null;settingsLoading:boolean;settingsError:string|null;settingsReceivedAt:string|null;snapshot:EvoBgpCatalogSnapshot|null;loading:boolean;catalogError:string|null;catalogReceivedAt:string|null;operationBusy:boolean}
const empty=(sourceKey:string,token:symbol):State=>({token,sourceKey,settings:null,settingsLoading:false,settingsError:null,settingsReceivedAt:null,snapshot:null,loading:false,catalogError:null,catalogReceivedAt:null,operationBusy:false})
type Session={token:symbol;key:string;active:boolean;settingsRevision:number;catalogRevision:number;settingsReader?:AbortController;catalogReader?:AbortController;operation?:AbortController;mutating:boolean;knownSettings:EvoBgpSettingsDto|null}
const message=(error:unknown)=>error instanceof Error?error.message:"Не удалось получить данные EvoBGP"
export function EvoBGPProvider({children}:{children:React.ReactNode}) {
const {mode,backendUrl,backendStatus,prefsHydrated}=useDataSource()
const sourceKey=JSON.stringify([mode,backendUrl])
const sourceToken=useMemo(()=>Symbol(sourceKey),[sourceKey])
const session=useRef<Session>({token:sourceToken,key:sourceKey,active:true,settingsRevision:0,catalogRevision:0,mutating:false,knownSettings:null})
const [stored,setStored]=useState<State>(()=>empty(sourceKey,sourceToken))
const state=stored.token===sourceToken?stored:empty(sourceKey,sourceToken)
const eligible=prefsHydrated&&mode==="live"&&backendStatus===true&&can("mm:settings:admin")
useEffect(()=>{const scope:Session={token:sourceToken,key:sourceKey,active:true,settingsRevision:0,catalogRevision:0,mutating:false,knownSettings:null};session.current=scope;return()=>{scope.active=false;scope.settingsReader?.abort();scope.catalogReader?.abort();scope.operation?.abort()}},[sourceKey,sourceToken])
const update=useCallback((patch:Partial<State>)=>{const scope=session.current;if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return;setStored(current=>({...(current.token===sourceToken?current:empty(scope.key,sourceToken)),...patch}))},[session,sourceKey,sourceToken])
const acceptSettings=useCallback((data:EvoBgpSettingsDto)=>{const scope=session.current;if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return;
const previous=scope.knownSettings
const changed=previous&&(previous.baseUrl!==data.baseUrl||previous.enabled!==data.enabled||previous.secretConfigured!==data.secretConfigured)
scope.knownSettings=data
update({settings:data,settingsError:null,settingsReceivedAt:new Date().toISOString(),...(changed?{snapshot:null,catalogError:null,catalogReceivedAt:null}:{})})
},[session,sourceKey,sourceToken,update])
const pullCatalog=useCallback(async(catalogEnabled:boolean)=>{const scope=session.current;
if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating||!eligible)return
if(!catalogEnabled){scope.catalogRevision++;scope.catalogReader?.abort();update({snapshot:null,catalogError:null,catalogReceivedAt:null,loading:false});return}
const revision=++scope.catalogRevision;scope.catalogReader?.abort();const controller=new AbortController();scope.catalogReader=controller
const current=()=>scope.active&&scope.catalogRevision===revision&&!controller.signal.aborted
update({loading:true})
try{const raw=await requestJson<unknown>(backendUrl,"/api/evobgp/catalog",{method:"POST",signal:AbortSignal.any([controller.signal,AbortSignal.timeout(30_000)])});const snapshot=parseEvoBgpCatalog(raw);if(current())update({snapshot,catalogError:null,catalogReceivedAt:new Date().toISOString()})}
catch(error){if(current())update({catalogError:message(error)})}
finally{if(current())update({loading:false})}
},[backendUrl,eligible,session,sourceKey,sourceToken,update])
const loadSettings=useCallback(async()=>{const scope=session.current;
if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating)return
if(!eligible){if(prefsHydrated&&mode==="live")update({settingsError:!can("mm:settings:admin")?"Нет доступа к настройкам EvoBGP":backendStatus===false?"Backend EvoBGP недоступен":null});return}
const revision=++scope.settingsRevision;scope.settingsReader?.abort();const controller=new AbortController();scope.settingsReader=controller
const current=()=>scope.active&&scope.settingsRevision===revision&&!controller.signal.aborted
update({settingsLoading:true})
try{const data=await getEvoBgpSettings(backendUrl,{signal:controller.signal});if(!current())return;acceptSettings(data);void pullCatalog(data.enabled)}
catch(error){if(current())update({settingsError:message(error)})}
finally{if(current())update({settingsLoading:false})}
},[acceptSettings,backendStatus,backendUrl,eligible,mode,prefsHydrated,pullCatalog,session,sourceKey,sourceToken,update])
useEffect(()=>{queueMicrotask(()=>{void loadSettings()})},[loadSettings])
const begin=useCallback(()=>{const scope=session.current;
if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active||scope.mutating||!eligible||!scope.knownSettings)throw new Error("Настройки EvoBGP ещё не подтверждены или действие недоступно")
scope.mutating=true;scope.settingsRevision++;scope.catalogRevision++;scope.settingsReader?.abort();scope.catalogReader?.abort();const controller=new AbortController();scope.operation=controller
update({operationBusy:true,settingsLoading:false,loading:false});return controller
},[eligible,session,sourceKey,sourceToken,update])
const finish=useCallback((expected:Session)=>{const scope=session.current;if(scope!==expected||scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return;scope.mutating=false;update({operationBusy:false})},[session,sourceKey,sourceToken,update])
const saveSettings=useCallback(async(patch:EvoBgpSavePayload)=>{const scope=session.current;
const controller=begin()
try{const data=await putEvoBgpSettings(backendUrl,patch,{signal:controller.signal});if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)throw new Error("Контекст EvoBGP изменился")
acceptSettings({baseUrl:data.baseUrl,enabled:data.enabled,secretConfigured:data.secretConfigured})
if(patch.apiKey!==undefined)update({snapshot:null,catalogError:null,catalogReceivedAt:null})
}finally{finish(scope)}
// Saving is complete before any independent catalog request; its failure cannot cancel the receipt.
if(scope.active&&scope.knownSettings)void pullCatalog(scope.knownSettings.enabled)
},[acceptSettings,backendUrl,begin,finish,pullCatalog,session,sourceKey,sourceToken,update])
const refresh=useCallback(async()=>{const scope=session.current;if(scope.token===sourceToken&&scope.key===sourceKey&&scope.knownSettings)await pullCatalog(scope.knownSettings.enabled)},[pullCatalog,session,sourceKey,sourceToken])
const testConnection=useCallback(async(draft?:EvoBgpTestDraft)=>{const scope=session.current;
let started=false
try{const controller=begin();started=true;await testEvoBgpConnection(backendUrl,draft,{signal:controller.signal});if(scope.token!==sourceToken||scope.key!==sourceKey||!scope.active)return {ok:false,message:"Контекст EvoBGP изменился"};return {ok:true,message:"Соединение с EvoBGP установлено"}}
catch(error){return {ok:false,message:message(error)}}finally{if(started)finish(scope)}
},[backendUrl,begin,finish,session,sourceKey,sourceToken])
const value:EvoBgpContextValue={baseUrl:state.settings?.baseUrl??"",enabled:state.settings?.enabled??false,secretConfigured:state.settings?.secretConfigured??false,settingsLoaded:!!state.settings,settingsLoading:state.settingsLoading,settingsError:state.settingsError,settingsReceivedAt:state.settingsReceivedAt,sourceKey,snapshot:state.snapshot,loading:state.loading,error:state.settingsError??state.catalogError,catalogError:state.catalogError,catalogReceivedAt:state.catalogReceivedAt,operationBusy:state.operationBusy,loadSettings,saveSettings,refresh,testConnection}
return <EvoBgpContext.Provider value={value}>{children}</EvoBgpContext.Provider>
}
export function useEvoBGP():EvoBgpContextValue{const context=useContext(EvoBgpContext);if(!context)throw new Error("useEvoBGP must be used within EvoBGPProvider");return context}
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { confirmEvoBgpConnection } from "./evobgp-test-receipt"
test("only explicit true confirms the connection", () => {
assert.doesNotThrow(() => confirmEvoBgpConnection({ ok: true }))
for (const raw of [null, {}, { ok: false }, { ok: 1 }, { ok: "true" }, []]) {
assert.throws(() => confirmEvoBgpConnection(raw), /не подтверждено сервером/)
}
})
+9
View File
@@ -0,0 +1,9 @@
import { z } from "zod"
const receipt = z.object({ ok: z.literal(true) })
export function confirmEvoBgpConnection(raw: unknown): void {
if (!receipt.safeParse(raw).success) {
throw new Error("Соединение с EvoBGP не подтверждено сервером")
}
}
+7
View File
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict'
import {filtersGreResponseSchema} from './filters-response'
const t={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'}
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[t]}).success,true)
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[{...t,ipsec:null}]}).success,true)
assert.equal(filtersGreResponseSchema.safeParse({tunnels:[{...t,ipsec:{secret:'must-not-be-in-catalog'}}]}).success,false)
console.log('Filters GRE catalog: configured IPsec accepted without key material')
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod"
const ruleSchema = z.object({
id: z.string().min(1), community: z.string(), communityName: z.string().optional(),
action: z.enum(["route", "blackhole"]).optional(), gateway: z.string(), gatewayTunnelId: z.string(), description: z.string(),
})
export const filterRulesSchema = z.array(ruleSchema).refine(rows => new Set(rows.map(r => r.id)).size === rows.length)
export const filtersResponseSchema = z.object({
rulesets: z.array(z.object({ serverId: z.string().min(1), rules: filterRulesSchema }))
.refine(rows => new Set(rows.map(r => r.serverId)).size === rows.length),
live: z.boolean().optional(), stale: z.boolean().optional(), error: z.string().optional(),
})
export const filtersMutationSchema = z.object({ ok: z.literal(true), rules: filterRulesSchema.optional() })
export const filtersGreResponseSchema = z.object({
tunnels: z.array(z.object({
id: z.string().min(1), serverId: z.string().min(1), name: z.string(), localAddress: z.string(), remoteAddress: z.string(),
localInnerIp: z.string(), remoteInnerIp: z.string(), poolId: z.string(),
ipsec: z.object({ secret: z.literal("") }).nullable(), mtu: z.number().int().nonnegative(),
keepaliveInterval: z.number().int().nonnegative(), keepaliveRetries: z.number().int().nonnegative(),
dscp: z.union([z.literal("inherit"), z.number().int().min(0).max(63)]), clampTcpMss: z.boolean(), allowFastPath: z.boolean(),
comment: z.string(), enabled: z.boolean(), status: z.enum(["up", "down", "degraded"]),
})).refine(rows => new Set(rows.map(r => r.id)).size === rows.length),
})
export const filtersRecursiveResponseSchema = z.object({
routes: z.array(z.object({ id: z.string().min(1), dstAddress: z.string(), gateway: z.string(), distance: z.number().int().nonnegative(), routingTable: z.string(), comment: z.string(), disabled: z.boolean() }))
.refine(rows => new Set(rows.map(r => r.id)).size === rows.length),
stale: z.boolean().optional(),
})
+14
View File
@@ -0,0 +1,14 @@
import { matchesFirewallFailure, type FirewallFailure } from "./firewall-response"
export type FirewallSliceTime = { serverId: string; family: "ip" | "ip6"; table: string; receivedAt: string | null }
export function updateFirewallSliceTimes(previous: FirewallSliceTime[], serverIds: string[], failures: FirewallFailure[], receivedAt: string): FirewallSliceTime[] {
return serverIds.flatMap(serverId => (["ip", "ip6"] as const).flatMap(family => ["filter", "nat", "mangle", "raw", "address-list"].map(table => {
const slice = { serverId, family, table }
const old = previous.find(row => row.serverId === serverId && row.family === family && row.table === table)
return { ...slice, receivedAt: failures.some(f => matchesFirewallFailure(f, slice)) ? old?.receivedAt ?? null : receivedAt }
})))
}
export function firewallTimeRange(slices: FirewallSliceTime[]) {
const times = slices.flatMap(slice => slice.receivedAt ? [slice.receivedAt] : []).sort()
return { oldest: times[0], newest: times.at(-1), unknown: slices.filter(slice => !slice.receivedAt).length }
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
const context = { id: z.string().min(1), rosId: z.string().min(1), serverId: z.string().min(1), serverName: z.string(), family: z.enum(["ip", "ip6"]) }
export const firewallFailureSchema = z.object({ serverId: z.string().min(1), serverName: z.string().optional(), family: z.enum(["ip", "ip6"]).optional(), table: z.enum(["filter", "nat", "mangle", "raw", "address-list"]).optional(), error: z.string() })
export type FirewallFailure = z.infer<typeof firewallFailureSchema>
export const firewallResponseSchema = z.object({
failures: z.array(firewallFailureSchema).default([]),
rules: z.array(z.object({
...context, table: z.enum(["filter", "nat", "mangle", "raw"]), chain: z.string(), action: z.string(),
proto: z.string(), src: z.string(), dst: z.string(), port: z.string(), iface: z.string(),
comment: z.string(), enabled: z.boolean(), hits: z.number().int().nonnegative(),
log: z.boolean().optional(), logPrefix: z.string().optional(), tlsHost: z.string().optional(),
connRateLimit: z.string().optional(), layer7Proto: z.string().optional(), nfqueueId: z.number().int().nonnegative().optional(),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
addressLists: z.array(z.object({ ...context, list: z.string(), address: z.string(), comment: z.string(), disabled: z.boolean(), timeout: z.string().optional() }))
.refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
})
export const firewallServersSchema = z.array(z.object({
id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(), country: z.string(),
type: z.enum(["jump-host", "exit-node", "home-router"]), site: z.string().default(""), asn: z.string().default(""),
status: z.enum(["online", "offline", "degraded"]).nullable().default(null), latency: z.number().finite().nullable().default(null),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
export type FirewallBackendServer = z.infer<typeof firewallServersSchema>[number]
export function matchesFirewallFailure(failure: FirewallFailure, row: { serverId?: string; family?: string; table?: string }, addressList = false) {
return failure.serverId === row.serverId && (!failure.family || failure.family === row.family)
&& (!failure.table || failure.table === (addressList ? "address-list" : row.table))
}
export function mergeFirewallRows<T extends { serverId?: string; family?: string; table?: string }>(previous: T[], incoming: T[], failures: FirewallFailure[], enabledServerIds: Set<string>, addressList = false): T[] {
return [...incoming.filter(row => !failures.some(f => matchesFirewallFailure(f, row, addressList))),
...previous.filter(row => !!row.serverId && enabledServerIds.has(row.serverId) && failures.some(f => matchesFirewallFailure(f, row, addressList)))]
}
+11
View File
@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
import { fmtRate, fmtGiB } from "./fmt-rate.js"
assert.equal(fmtRate(null),"—");assert.equal(fmtRate(undefined),"—");assert.equal(fmtRate(0),"0 Кбит/с")
assert.equal(fmtRate(0.000001),"<1 Кбит/с");assert.equal(fmtRate(0.001),"1 Кбит/с")
assert.equal(fmtRate(1),"1.00 Мбит/с");assert.equal(fmtRate(1000),"1.00 Гбит/с")
console.log("rate formatting unknown/zero/positive-small/units tests ok")
assert.equal(fmtGiB(null),"—");assert.equal(fmtGiB(0),"0 Б")
assert.equal(fmtGiB(1/1024**3),"1 Б");assert.equal(fmtGiB(1/1024**2),"1 КиБ")
assert.equal(fmtGiB(1/1024),"1 МиБ");assert.equal(fmtGiB(1),"1 ГиБ");assert.equal(fmtGiB(1024),"1 ТиБ")
assert.equal(fmtGiB(100/1024),"100 МиБ");assert.equal(fmtGiB(Number.MAX_VALUE),"—")
console.log("binary traffic volume precision/unit boundary tests ok")
+52
View File
@@ -0,0 +1,52 @@
/** Формат скорости: Кбит/с при < 1 Мбит/с, иначе Мбит/с / Гбит/с. */
export function fmtRate(v: number | null | undefined): string {
if (v == null) return "—"
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
if (v < 0.0005) return "<1 Кбит/с"
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
if (v < 10) return `${v.toFixed(2)} Мбит/с`
if (v < 100) return `${v.toFixed(1)} Мбит/с`
return `${Math.round(v)} Мбит/с`
}
const binaryFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 })
/** Traffic API volumeUnit=GiB: preserve binary units and never format positive bytes as zero. */
export function fmtGiB(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v) || v < 0) return "—"
const bytes = v * 1024 ** 3
if (!Number.isFinite(bytes)) return "—"
if (bytes === 0) return "0 Б"
if (bytes < 1) return "<1 Б"
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ"]
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
return `${binaryFormatter.format(bytes / 1024 ** index)} ${units[index]}`
}
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 Б"
if (n >= 1_000_000_000_000) return `${(n / 1_000_000_000_000).toFixed(2)} ТБ`
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
return `${Math.round(n)} Б`
}
/** API bitrate is bits/s; fmtRate expects Мбит/с. */
export function fmtBps(bps: number): string {
return fmtRate(bps / 1_000_000)
}
export const TRAFFIC_RANGE_MINUTES: Record<string, number> = {
"5m": 5,
"15m": 15,
"1h": 60,
"4h": 240,
"24h": 1440,
}
export function formatRangeAgo(minutesAgo: number): string {
if (minutesAgo <= 0) return "сейчас"
if (minutesAgo < 60) return `−${minutesAgo}м`
return `−${Math.round(minutesAgo / 60)}ч`
}
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict"
import {
formatServicePathLabel,
formatServicePathTitle,
isUnboundServicePath,
} from "./format-service-path-label"
const bound = {
clientId: "u1",
clientName: "D",
viaId: "7",
viaName: "nsk-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
const jhEn = {
clientId: "—",
clientName: "—",
viaId: "7",
viaName: "msk-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
const enOnly = {
clientId: "—",
clientName: "—",
viaId: "9",
viaName: "arn-gw01",
enId: "9",
enName: "arn-gw01",
serviceId: "svc:cdn",
}
assert.equal(isUnboundServicePath(jhEn), true)
assert.equal(isUnboundServicePath(bound), false)
assert.equal(
formatServicePathLabel(jhEn, "via", { viaName: "msk-gw01.rtnt.top", enName: "arn-gw01.rtnt.top" }),
"msk-gw01.rtnt.top → arn-gw01.rtnt.top",
)
assert.equal(formatServicePathLabel(enOnly, "via"), "arn-gw01 · без привязки")
assert.equal(formatServicePathLabel(enOnly, "service"), "arn-gw01 · без привязки")
assert.equal(
formatServicePathLabel(bound, "via", { viaSite: "NSK" }),
"D · NSK",
)
assert.equal(
formatServicePathLabel(bound, "service", { serviceLabel: "CDN" }),
"D · CDN",
)
assert.equal(
formatServicePathTitle("msk-gw01 → arn-gw01", "CDN"),
"msk-gw01 → arn-gw01 · CDN",
)
console.log("format-service-path-label.test.ts: ok")
+45
View File
@@ -0,0 +1,45 @@
export const UNBOUND_PATH_CLIENT = "—"
export interface ServicePathLabelInput {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
}
export interface ServicePathLabelNames {
viaName?: string
viaSite?: string
enName?: string
serviceLabel?: string
}
export function isUnboundServicePath(p: Pick<ServicePathLabelInput, "clientId" | "clientName">): boolean {
return p.clientId === UNBOUND_PATH_CLIENT || p.clientName === UNBOUND_PATH_CLIENT
}
export function formatServicePathLabel(
p: ServicePathLabelInput,
viaMode: "via" | "service",
names: ServicePathLabelNames = {},
): string {
const viaName = names.viaName || p.viaName
const enName = names.enName || p.enName
if (isUnboundServicePath(p)) {
if (p.viaId !== p.enId) return `${viaName} → ${enName}`
return `${enName} · без привязки`
}
const viaLabel = names.viaSite || p.viaName
const mid = viaMode === "via" ? viaLabel : (names.serviceLabel || p.serviceId)
return `${p.clientName} · ${mid}`
}
export function formatServicePathTitle(label: string, serviceLabel: string): string {
const svc = serviceLabel.trim()
if (!svc) return label
if (label.includes(svc)) return label
return `${label} · ${svc}`
}
+89
View File
@@ -0,0 +1,89 @@
const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id) — как в network-map-layout. */
export function normalizeGreEndpointAddr(addr: string): string {
return addr.trim().split("/")[0].split("%")[0].trim()
}
/** Явный IPv4 (для отличия от FQDN в remote-address GRE). */
export function isIpv4String(s: string): boolean {
return IPV4.test(s.trim())
}
/**
* Сопоставление IP WAN в каталоге с outer GRE: прямой IP или FQDN,
* зарезолвенный в API (hostname lower-case → A-запись).
*/
export function wanUplinkIpMatchesGreOuter(
wanIp: string,
greOuter: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): boolean {
const a = normalizeGreEndpointAddr(wanIp)
const b = normalizeGreEndpointAddr(greOuter)
if (!a || !b) return false
if (a === b) return true
if (resolvedIpv4ByHost) {
const rb = resolvedIpv4ByHost.get(b.toLowerCase())
if (rb && a === normalizeGreEndpointAddr(rb)) return true
const ra = resolvedIpv4ByHost.get(a.toLowerCase())
if (ra && b === normalizeGreEndpointAddr(ra)) return true
if (ra && rb && normalizeGreEndpointAddr(ra) === normalizeGreEndpointAddr(rb)) return true
}
return false
}
/** Все варианты внешнего IP для сопоставления с каталогом (IP и зарезолвенный FQDN). */
export function greOuterComparableIps(
outer: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string[] {
const n = normalizeGreEndpointAddr(outer)
if (!n || n === "0.0.0.0") return []
const out: string[] = []
if (isIpv4String(n)) out.push(n)
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
if (r) {
const ip = normalizeGreEndpointAddr(r)
if (ip && isIpv4String(ip) && !out.includes(ip)) out.push(ip)
}
return out
}
/** Собрать уникальные FQDN из local/remote-address (без чистых IPv4). */
export function collectGreEndpointHostnames(
tunnels: Array<{ localAddress: string; remoteAddress: string }>,
): string[] {
const set = new Set<string>()
for (const t of tunnels) {
for (const a of [t.localAddress, t.remoteAddress]) {
const n = normalizeGreEndpointAddr(a)
if (!n || n === "0.0.0.0") continue
if (!isIpv4String(n)) set.add(n.toLowerCase())
}
}
return [...set]
}
/** Ответ POST /api/network/resolve-hosts → Map для матчинга. */
export function greResolvedMapFromApi(
results: Record<string, string | null> | undefined,
): ReadonlyMap<string, string> {
const m = new Map<string, string>()
if (!results) return m
for (const [k, v] of Object.entries(results)) {
if (v && isIpv4String(v)) m.set(k.toLowerCase(), normalizeGreEndpointAddr(v))
}
return m
}
/** Подпись outer на карте: при FQDN дополнительно показываем разрешённый IPv4. */
export function formatGreOuterForDisplay(
addr: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string {
const n = normalizeGreEndpointAddr(addr) || "?"
if (isIpv4String(n)) return n
const r = resolvedIpv4ByHost?.get(n.toLowerCase())
return r ? `${n} (${r})` : n
}
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict"
import type { GreTunnel } from "./data"
import { generateGreRsc } from "./gre-export"
import { quoteRosString } from "./vxlan-export"
const tunnel: GreTunnel = { id: "*1", serverId: "1", name: 'gre "edge" $name\n/system reboot', localAddress: "0.0.0.0", remoteAddress: "192.0.2.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: { secret: 'key"\\$value\n/system reboot', encAlg: "aes-256", authAlg: "sha256", ikeVersion: "ikev2" }, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: false, comment: 'quoted "comment"\n/system reboot', enabled: false, status: "down" }
const code = generateGreRsc(tunnel, {})
const commands = code.split("\n").filter(line => line.startsWith("/"))
assert.equal(commands.length, 2)
assert.ok(commands[0].includes("name=" + quoteRosString(tunnel.name)))
assert.ok(commands[0].includes("ipsec-secret=" + quoteRosString(tunnel.ipsec!.secret)))
assert.ok(commands[0].includes("comment=" + quoteRosString(tunnel.comment)))
assert.ok(commands[0].includes("keepalive=10s,3 dscp=inherit clamp-tcp-mss=yes allow-fast-path=no disabled=yes"))
assert.ok(commands[1].includes('address="10.0.0.1/30" interface=' + quoteRosString(tunnel.name)))
for (const unsupported of ["/ip ipsec peer add", "/ip/ipsec/peer/add", "proposal add", "policy add", "exchange-mode=", "local-address=", "\n/system reboot", "10.0.0.2/30"]) assert.ok(!code.includes(unsupported), unsupported)
const noInner = generateGreRsc({ ...tunnel, localInnerIp: "", ipsec: null, localAddress: "192.0.2.10", keepaliveInterval: 0, dscp: 12, enabled: true }, {})
assert.equal(noInner.split("\n").filter(line => line.startsWith("/")).length, 1)
assert.ok(noInner.includes('local-address="192.0.2.10"'))
assert.ok(noInner.includes("keepalive=<KEEPALIVE_SECONDS>s,<KEEPALIVE_RETRIES> dscp=12"))
assert.ok(!noInner.includes("ipsec-secret="))
console.log("gre-export: escaped strings, observed GRE/PSK only, optional IP and no duplicate IPsec passed")
const masked = generateGreRsc({ ...tunnel, ipsec: { secret: '' } }, {})
assert.ok(masked.includes('ipsec-secret="<IPSEC_PSK>"'))
assert.ok(masked.includes('# Шаблон: ключ IPsec не раскрыт.'))
assert.ok(!masked.includes('ipsec-secret=""'))
console.log('gre-export: hidden configured PSK produces an explicitly labelled template, never an empty live key')
const incompatible = generateGreRsc({ ...tunnel, allowFastPath: true }, {})
assert.ok(incompatible.includes("allow-fast-path=<ALLOW_FAST_PATH>"))
assert.ok(incompatible.includes("IPsec несовместим с FastPath"))
const ipv6 = generateGreRsc({ ...tunnel, ipsec: null, localInnerIp: "fd00:213::1/64" }, {})
assert.ok(ipv6.includes('/ipv6/address/add address="fd00:213::1/64" interface=' + quoteRosString(tunnel.name) + " advertise=no"))
assert.ok(!ipv6.includes("/ip/address/add"))
assert.ok(ipv6.includes("pool/EUI-64 и остальные адреса не включены"))
+44
View File
@@ -0,0 +1,44 @@
import type { GreTunnel, Server } from "./data"
import { quoteRosString } from "./vxlan-export"
const commentLine = (value: string) => value.replace(/[\r\n\x00-\x1f\x7f]/g, " ")
const validKeepalive = (t: GreTunnel) => [t.keepaliveInterval, t.keepaliveRetries].every(value => Number.isInteger(value) && value >= 1 && value <= 4294967295)
export function greExportWarnings(t: GreTunnel): string[] {
return [
t.ipsec && !t.ipsec.secret ? "Шаблон: ключ IPsec скрыт. Укажите ключ перед применением." : "",
!validKeepalive(t) ? "Шаблон: задайте допустимый интервал keepalive и число попыток перед применением." : "",
t.ipsec && t.allowFastPath ? "Шаблон: IPsec несовместим с FastPath. Проверьте этот параметр перед применением." : "",
].filter(Boolean)
}
/** GRE/PSK fragment: https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE */
export function generateGreRsc(t: GreTunnel, servers: Record<string, Server>): string {
const server = servers[t.serverId]
const lines = ["# GRE — " + commentLine(t.name)]
if (server) lines.push("# Сервер: " + commentLine(server.name) + " (" + commentLine(server.host) + ")")
lines.push("# Фрагмент интерфейса и локального IP; удалённая сторона и маршруты не включены", "")
if (!validKeepalive(t)) lines.push("# Шаблон: задайте KEEPALIVE_SECONDS и KEEPALIVE_RETRIES — целые значения от 1 до 4294967295.")
if (t.ipsec && t.allowFastPath) lines.push("# Шаблон: IPsec несовместим с FastPath. Проверьте ALLOW_FAST_PATH перед применением.")
const args = [
"name=" + quoteRosString(t.name), "remote-address=" + quoteRosString(t.remoteAddress),
"mtu=" + t.mtu, "keepalive=" + (validKeepalive(t) ? t.keepaliveInterval + "s," + t.keepaliveRetries : "<KEEPALIVE_SECONDS>s,<KEEPALIVE_RETRIES>"),
"dscp=" + t.dscp, "clamp-tcp-mss=" + (t.clampTcpMss ? "yes" : "no"),
"allow-fast-path=" + (t.ipsec && t.allowFastPath ? "<ALLOW_FAST_PATH>" : t.allowFastPath ? "yes" : "no"), "disabled=" + (t.enabled ? "no" : "yes"),
"comment=" + quoteRosString(t.comment),
]
if (t.localAddress && t.localAddress !== "0.0.0.0") args.push("local-address=" + quoteRosString(t.localAddress))
if (t.ipsec) {
if (!t.ipsec.secret) lines.push("# Шаблон: ключ IPsec не раскрыт. Перед применением замените <IPSEC_PSK> своим ключом.")
args.push("ipsec-secret=" + quoteRosString(t.ipsec.secret || "<IPSEC_PSK>"))
}
lines.push("/interface/gre/add " + args.join(" "))
if (t.localInnerIp) {
const ipv6 = t.localInnerIp.includes(":")
if (ipv6) lines.push("# IPv6: новый статический адрес без объявления префикса; pool/EUI-64 и остальные адреса не включены")
lines.push((ipv6 ? "/ipv6/address/add" : "/ip/address/add") + " address=" + quoteRosString(t.localInnerIp) + " interface=" + quoteRosString(t.name) + (ipv6 ? " advertise=no" : ""))
}
if (t.ipsec) lines.push("# Peer/policy создаёт RouterOS по ipsec-secret; ручные объекты IPsec не экспортируются")
if (t.ipsec && t.allowFastPath) lines.push("# В снимке включён FastPath: для GRE с IPsec документация RouterOS требует allow-fast-path=no")
return lines.join("\n") + "\n"
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from "zod"
import { filtersGreResponseSchema } from "./filters-response"
export const greResponseSchema = z.object({
tunnels: z.array(filtersGreResponseSchema.shape.tunnels.element.extend({
ipsec: z.object({
secret: z.string(), encAlg: z.enum(["aes-128", "aes-192", "aes-256"]).optional(),
authAlg: z.enum(["sha1", "sha256", "sha512"]).optional(),
dhGroup: z.enum(["modp1024", "modp2048", "modp4096", "ecp256", "ecp384", "ecp521"]).optional(),
ikeVersion: z.enum(["ikev1", "ikev2"]).optional(), lifetime: z.string().optional(), pfs: z.boolean().optional(),
}).nullable(),
})).refine(rows => new Set(rows.map(t => JSON.stringify([t.serverId, t.id]))).size === rows.length),
failures: z.array(z.object({ serverId: z.string().trim().min(1), serverName: z.string().optional(), error: z.string() })).default([]),
}).refine(data => new Set(data.failures.map(f => f.serverId)).size === data.failures.length && !data.tunnels.some(t => data.failures.some(f => f.serverId === t.serverId)))
+33
View File
@@ -0,0 +1,33 @@
/** Клиентские хелперы IPsec (зеркало чистых функций бэкенда). */
function parseIpv4(s: string): number | null {
const parts = s.trim().split(".")
if (parts.length !== 4) return null
const octets = parts.map((p) => Number(p))
if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return null
return (((octets[0]! << 24) | (octets[1]! << 16) | (octets[2]! << 8) | octets[3]!) >>> 0)
}
function intToIp(n: number): string {
return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".")
}
/** Первый свободный IP диапазона пула («a.b.c.d-a.b.c.e»), исключая занятые. */
export function findFreePoolIp(range: string, taken: Iterable<string>): string | null {
const takenSet = new Set(
Array.from(taken, (t) => t.replace(/\/\d+$/, "").trim()),
)
const first = range.split(",").map((s) => s.trim()).filter(Boolean)[0] ?? ""
const [fromRaw, toRaw] = first.split("-")
const from = parseIpv4(fromRaw ?? "")
const to = parseIpv4(toRaw ?? fromRaw ?? "")
if (from == null) return null
const last = to ?? from
if (last < from) return null
const cap = Math.min(last, from + 65_534)
for (let n = from; n <= cap; n++) {
const ip = intToIp(n)
if (!takenSet.has(ip)) return ip
}
return null
}
+490
View File
@@ -0,0 +1,490 @@
import type { GreTunnel, Server } from "@/lib/data"
import type { WanJhEdge } from "@/lib/network-map-layout"
import {
formatGreOuterForDisplay,
normalizeGreEndpointAddr,
wanUplinkIpMatchesGreOuter,
} from "@/lib/gre-endpoint-resolve"
/** Поля speed-пробы из мониторинга (Мониторинг → скорость), нужные для GRE на карте. */
export interface GreSpeedProbeSnapshot {
id: string
srcServerId: string
dstServerId: string
enabled?: boolean
srcInterface?: string
dstInterface?: string
lastTxAvgMbps?: number | null
lastRxAvgMbps?: number | null
lastPingAt?: string | null
lastPingRttMs?: number | null
}
export type GreTunnelProbeEdge = {
tunnel: GreTunnel
fromServer: Server
toServer: Server
}
/** Неориентированная пара узлов: один ключ для HR→MSK и MSK→HR (две строки GRE в БД). */
export function canonicalGreServerPairKey(a: Pick<Server, "id">, b: Pick<Server, "id">): string {
const x = String(a.id)
const y = String(b.id)
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
}
function normIface(i?: string | null): string {
return (i ?? "").trim().toLowerCase()
}
/** WAN-интерфейс узла, чей внешний IP совпадает с GRE outer (endpoint каталога). */
export function ifaceNameForGreOuterIp(
server: Server,
outerIp: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string | null {
const r = normalizeGreEndpointAddr(outerIp)
if (!r || r === "0.0.0.0") return null
for (const w of server.wanUplinks ?? []) {
if (wanUplinkIpMatchesGreOuter(w.ip, outerIp, resolvedIpv4ByHost)) return w.iface.trim()
}
return null
}
/** Короткая подпись для бейджа: внешние IP и имена WAN при наличии в каталоге. */
export function greOuterSummaryLine(
tunnel: GreTunnel,
fromServer: Server,
toServer: Server,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string {
const lo = formatGreOuterForDisplay(tunnel.localAddress, resolvedIpv4ByHost) || "auto"
const ro = formatGreOuterForDisplay(tunnel.remoteAddress, resolvedIpv4ByHost) || "?"
const li = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
const ri = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
const ipPart = `${lo}→${ro}`
if (li || ri) return `${ipPart} · ${li ?? "?"}→${ri ?? "?"}`
return ipPart
}
function scoreProbeForGreTunnel(
p: GreSpeedProbeSnapshot,
tunnel: GreTunnel,
fromServer: Server,
toServer: Server,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): number {
const pf = String(p.srcServerId)
const pt = String(p.dstServerId)
const fid = String(fromServer.id)
const tid = String(toServer.id)
const forward = pf === fid && pt === tid
const backward = pf === tid && pt === fid
if (!forward && !backward) return -1
const si = normIface(p.srcInterface)
const di = normIface(p.dstInterface)
let s = 0
if (forward) {
const srcW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
const dstW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
const srcM = !!(srcW && si === normIface(srcW))
const dstM = !!(dstW && di === normIface(dstW))
if (srcM) s += 52
if (dstM) s += 52
if (srcM && dstM) s += 34
if (!srcW && dstW && di === normIface(dstW)) s += 45
if (!dstW && srcW && si === normIface(srcW)) s += 45
} else {
const srcW = ifaceNameForGreOuterIp(toServer, tunnel.remoteAddress, resolvedIpv4ByHost)
const dstW = ifaceNameForGreOuterIp(fromServer, tunnel.localAddress, resolvedIpv4ByHost)
const srcM = !!(srcW && si === normIface(srcW))
const dstM = !!(dstW && di === normIface(dstW))
if (srcM) s += 52
if (dstM) s += 52
if (srcM && dstM) s += 34
if (!srcW && dstW && di === normIface(dstW)) s += 45
if (!dstW && srcW && si === normIface(srcW)) s += 45
}
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null)
s += 4
return s
}
function probesForServerPair(
probes: GreSpeedProbeSnapshot[],
fromServer: Server,
toServer: Server,
): GreSpeedProbeSnapshot[] {
const fid = String(fromServer.id)
const tid = String(toServer.id)
return probes.filter((p) => {
if (p.enabled === false) return false
const pf = String(p.srcServerId)
const pt = String(p.dstServerId)
return (pf === fid && pt === tid) || (pf === tid && pt === fid)
})
}
/**
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
*
* Пара узлов **неориентированная**: туннель в БД на HR→MSK и зеркальная запись MSK→HR
* попадают в одну группу — иначе обе стороны независимо выбирают одни и те же пробы
* и бейджи дублируются на разных линиях карты.
*/
export function assignSpeedProbesToGreTunnels(
edges: GreTunnelProbeEdge[],
probes: GreSpeedProbeSnapshot[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
const byPair = new Map<string, GreTunnelProbeEdge[]>()
for (const e of edges) {
const k = canonicalGreServerPairKey(e.fromServer, e.toServer)
const arr = byPair.get(k) ?? []
arr.push(e)
byPair.set(k, arr)
}
for (const group of byPair.values()) {
const assigned = assignProbesWithinPair(group, probes, resolvedIpv4ByHost)
for (const [tunnelId, sp] of assigned) result.set(tunnelId, sp)
}
return result
}
function assignProbesWithinPair(
edges: GreTunnelProbeEdge[],
allProbes: GreSpeedProbeSnapshot[],
resolved?: ReadonlyMap<string, string>,
): Map<string, GreSpeedProbeSnapshot | undefined> {
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
if (edges.length === 0) return out
const E = [...edges].sort((a, b) => a.tunnel.id.localeCompare(b.tunnel.id))
const pairProbes = probesForServerPair(allProbes, E[0].fromServer, E[0].toServer)
if (E.length === 1) {
out.set(
E[0].tunnel.id,
findSpeedProbeForGreEdge(E[0].tunnel, E[0].fromServer, E[0].toServer, allProbes, resolved),
)
return out
}
const n = E.length
const m = pairProbes.length
if (m === 0) {
for (const e of E) out.set(e.tunnel.id, undefined)
return out
}
// Полное назначение: каждому туннелю своя проба, максимум суммы score (RT и MTS не делят одну пробу).
let bestSum = -Infinity
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
if (i === n) {
let sum = 0
for (const e of E) {
const p = cur.get(e.tunnel.id)
if (!p) return
sum += scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
}
if (sum > bestSum) {
bestSum = sum
bestAssign = new Map(cur)
}
return
}
const edge = E[i]!
for (const p of pairProbes) {
if (used.has(p.id)) continue
if (scoreProbeForGreTunnel(p, edge.tunnel, edge.fromServer, edge.toServer, resolved) < 0)
continue
used.add(p.id)
cur.set(edge.tunnel.id, p)
dfs(i + 1, used, cur)
cur.delete(edge.tunnel.id)
used.delete(p.id)
}
}
// Перебор инъекций только при небольшом n — иначе сразу жадный алгоритм.
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
/** TS не видит присваивание из замыкания dfs — явное приведение. */
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
if (inject !== null && inject.size === n) {
for (const ed of E) out.set(ed.tunnel.id, inject.get(ed.tunnel.id))
return out
}
// Недостаточно проб или нет полного матчинга — жадно по убыванию score, без повторов.
const used = new Set<string>()
const remaining = new Set(E.map((e) => e.tunnel.id))
while (remaining.size > 0) {
let pickEdge: GreTunnelProbeEdge | undefined
let pickProbe: GreSpeedProbeSnapshot | undefined
let pickScore = -Infinity
for (const tid of remaining) {
const e = E.find((x) => x.tunnel.id === tid)!
for (const p of pairProbes) {
if (used.has(p.id)) continue
const sc = scoreProbeForGreTunnel(p, e.tunnel, e.fromServer, e.toServer, resolved)
if (sc < 0) continue
if (
sc > pickScore ||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
) {
pickScore = sc
pickEdge = e
pickProbe = p
}
}
}
if (!pickEdge || !pickProbe) {
for (const tid of remaining) out.set(tid, undefined)
break
}
out.set(pickEdge.tunnel.id, pickProbe)
used.add(pickProbe.id)
remaining.delete(pickEdge.tunnel.id)
}
return out
}
/**
* Подбирает speed-пробу для одного GRE (без учёта соседних туннелей той же пары узлов).
* Для нескольких туннелей между теми же серверами используйте assignSpeedProbesToGreTunnels.
*/
export function findSpeedProbeForGreEdge(
tunnel: GreTunnel,
fromServer: Server,
toServer: Server,
probes: GreSpeedProbeSnapshot[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): GreSpeedProbeSnapshot | undefined {
const cand = probes.filter(
(p) =>
p.enabled !== false &&
scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost) >= 0,
)
if (cand.length === 0) return undefined
let best = cand[0]!
let bestScore = scoreProbeForGreTunnel(best, tunnel, fromServer, toServer, resolvedIpv4ByHost)
for (let i = 1; i < cand.length; i++) {
const p = cand[i]!
const sc = scoreProbeForGreTunnel(p, tunnel, fromServer, toServer, resolvedIpv4ByHost)
if (sc > bestScore) {
best = p
bestScore = sc
continue
}
if (sc === bestScore) {
const hasData = (x: GreSpeedProbeSnapshot) =>
x.lastPingRttMs != null || x.lastTxAvgMbps != null || x.lastRxAvgMbps != null
if (hasData(p) && !hasData(best)) best = p
}
}
return best
}
export function mergeGreMetricsWithSpeedProbe(
sp: GreSpeedProbeSnapshot | undefined,
fallback: { pingMs: number | null; dlMbps: number | null; ulMbps: number | null },
): {
pingMs: number | null
dlMbps: number | null
ulMbps: number | null
hasSpeedMonitor: boolean
} {
const hasSpeedMonitor =
sp != null &&
(sp.lastPingRttMs != null || sp.lastTxAvgMbps != null || sp.lastRxAvgMbps != null)
const pingMs = sp?.lastPingRttMs ?? fallback.pingMs
const dlMbps =
sp?.lastTxAvgMbps != null ? sp.lastTxAvgMbps : fallback.dlMbps
const ulMbps =
sp?.lastRxAvgMbps != null ? sp.lastRxAvgMbps : fallback.ulMbps
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
}
// ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ──
export function wanJhEdgeMapKey(edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">): string {
return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`
}
function scoreSpeedProbeForWanJhEdge(
p: GreSpeedProbeSnapshot,
edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">,
home: Server,
): number {
if (String(p.srcServerId) !== edge.homeId || String(p.dstServerId) !== edge.jhId) return -1
if (p.enabled === false) return -1
const wanIface = normIface(home.wanUplinks?.[edge.wanIdx]?.iface)
const si = normIface(p.srcInterface)
if (si && wanIface && si !== wanIface) return -1
let s = 0
if (si && wanIface && si === wanIface) s += 100
else if (!si && wanIface) s += 35
else if (!wanIface && si) s += 15
else if (!si && !wanIface) s += 5
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 6
return s
}
function probesHomeToJh(allProbes: GreSpeedProbeSnapshot[], homeId: string, jhId: string): GreSpeedProbeSnapshot[] {
return allProbes.filter((p) => {
if (p.enabled === false) return false
return String(p.srcServerId) === homeId && String(p.dstServerId) === jhId
})
}
function assignSpeedProbesWithinWanJhGroup(
edges: WanJhEdge[],
home: Server,
allProbes: GreSpeedProbeSnapshot[],
): Map<string, GreSpeedProbeSnapshot | undefined> {
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
if (edges.length === 0) return out
const E = [...edges].sort((a, b) => a.wanIdx - b.wanIdx || a.jhId.localeCompare(b.jhId))
const pairProbes = probesHomeToJh(allProbes, E[0].homeId, E[0].jhId)
if (E.length === 1) {
let best: GreSpeedProbeSnapshot | undefined
let bestS = -1
for (const p of pairProbes) {
const sc = scoreSpeedProbeForWanJhEdge(p, E[0], home)
if (sc > bestS) {
bestS = sc
best = p
}
}
out.set(wanJhEdgeMapKey(E[0]), best != null && bestS >= 0 ? best : undefined)
return out
}
const n = E.length
const m = pairProbes.length
if (m === 0) {
for (const e of E) out.set(wanJhEdgeMapKey(e), undefined)
return out
}
let bestSum = -Infinity
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
if (i === n) {
let sum = 0
for (const e of E) {
const p = cur.get(wanJhEdgeMapKey(e))
if (!p) return
sum += scoreSpeedProbeForWanJhEdge(p, e, home)
}
if (sum > bestSum) {
bestSum = sum
bestAssign = new Map(cur)
}
return
}
const edge = E[i]!
const slotKey = wanJhEdgeMapKey(edge)
for (const p of pairProbes) {
if (used.has(p.id)) continue
if (scoreSpeedProbeForWanJhEdge(p, edge, home) < 0) continue
used.add(p.id)
cur.set(slotKey, p)
dfs(i + 1, used, cur)
cur.delete(slotKey)
used.delete(p.id)
}
}
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
if (inject !== null && inject.size === n) {
for (const ed of E) out.set(wanJhEdgeMapKey(ed), inject.get(wanJhEdgeMapKey(ed)))
return out
}
const used = new Set<string>()
const remaining = new Set(E.map((e) => wanJhEdgeMapKey(e)))
while (remaining.size > 0) {
let pickEdge: WanJhEdge | undefined
let pickProbe: GreSpeedProbeSnapshot | undefined
let pickScore = -Infinity
for (const key of remaining) {
const e = E.find((x) => wanJhEdgeMapKey(x) === key)!
for (const p of pairProbes) {
if (used.has(p.id)) continue
const sc = scoreSpeedProbeForWanJhEdge(p, e, home)
if (sc < 0) continue
if (
sc > pickScore ||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
) {
pickScore = sc
pickEdge = e
pickProbe = p
}
}
}
if (!pickEdge || !pickProbe) {
for (const key of remaining) out.set(key, undefined)
break
}
out.set(wanJhEdgeMapKey(pickEdge), pickProbe)
used.add(pickProbe.id)
remaining.delete(wanJhEdgeMapKey(pickEdge))
}
return out
}
/**
* Назначает speed-пробы сегментам WAN→JH (Home → спутник → JH): по паре серверов и iface источника,
* без повторного использования одной пробы на два WAN одного home→jh (RT / MTS).
*/
export function assignSpeedProbesToWanJhEdges(
edges: WanJhEdge[],
servers: Server[],
probes: GreSpeedProbeSnapshot[],
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const))
const byPair = new Map<string, WanJhEdge[]>()
for (const e of edges) {
const k = `${e.homeId}\t${e.jhId}`
const arr = byPair.get(k) ?? []
arr.push(e)
byPair.set(k, arr)
}
for (const group of byPair.values()) {
const home = homeById.get(group[0].homeId)
if (!home) {
for (const e of group) result.set(wanJhEdgeMapKey(e), undefined)
continue
}
const assigned = assignSpeedProbesWithinWanJhGroup(group, home, probes)
for (const [k, sp] of assigned) result.set(k, sp)
}
return result
}
+103
View File
@@ -0,0 +1,103 @@
import type { FlowMapHop } from "@mmapp/contracts/traffic-flow"
import { fmtRate } from "@/lib/fmt-rate"
export interface MatchedNetflowHop {
bps: number
bpsFwd: number
bpsRev: number
bytes: number
}
function ifaceNorm(s: string | undefined): string {
return (s ?? "").trim().toLowerCase()
}
function pairKey(a: string, b: string): string {
const x = String(a)
const y = String(b)
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
}
function mergeDirected(hops: FlowMapHop[], mapFromId: string): MatchedNetflowHop {
let bytes = 0
let bpsFwd = 0
let bpsRev = 0
const from = String(mapFromId)
for (const h of hops) {
bytes += h.bytes
if (h.fromId === from) {
bpsFwd += h.bpsFwd
bpsRev += h.bpsRev
} else {
bpsFwd += h.bpsRev
bpsRev += h.bpsFwd
}
}
return { bytes, bpsFwd, bpsRev, bps: bpsFwd + bpsRev }
}
export function hopHasRate(h: MatchedNetflowHop | undefined): h is MatchedNetflowHop {
return h != null && Number.isFinite(h.bps) && h.bps > 0
}
export function formatNetflowRate(hop: MatchedNetflowHop): string {
return fmtRate(hop.bps / 1_000_000)
}
export function formatNetflowDir(hop: MatchedNetflowHop): string {
return `↓${fmtRate(hop.bpsFwd / 1_000_000)} ↑${fmtRate(hop.bpsRev / 1_000_000)}`
}
/** GRE: сначала имя интерфейса туннеля на любом конце, иначе пара узлов. */
export function matchNetflowForGreEdge(
edge: {
tunnel: { name: string }
fromServer: { id: string }
toServer: { id: string }
},
hops: FlowMapHop[],
): MatchedNetflowHop | undefined {
const name = ifaceNorm(edge.tunnel.name)
const fromId = String(edge.fromServer.id)
const toId = String(edge.toServer.id)
if (name) {
const ifaceHits = hops.filter((h) =>
h.kind === "iface"
&& ifaceNorm(h.iface) === name
&& (h.fromId === fromId || h.fromId === toId),
)
if (ifaceHits.length) return mergeDirected(ifaceHits, fromId)
const greNamed = hops.filter((h) =>
h.kind === "gre"
&& ifaceNorm(h.iface) === name
&& (h.fromId === fromId || h.fromId === toId || h.toId === fromId || h.toId === toId),
)
if (greNamed.length) return mergeDirected(greNamed, fromId)
}
const want = pairKey(fromId, toId)
const pairHits = hops.filter((h) =>
h.kind === "gre" && Boolean(h.toId) && pairKey(h.fromId, h.toId) === want,
)
if (pairHits.length) return mergeDirected(pairHits, fromId)
return undefined
}
/** WAN-аплинк HR: kind wan, иначе iface с тем же именем на homeId. */
export function matchNetflowForWan(
homeId: string,
wanIface: string,
hops: FlowMapHop[],
): MatchedNetflowHop | undefined {
const id = String(homeId)
const iface = ifaceNorm(wanIface)
if (!iface) return undefined
const wanHits = hops.filter((h) =>
h.kind === "wan" && h.fromId === id && ifaceNorm(h.iface) === iface,
)
if (wanHits.length) return mergeDirected(wanHits, id)
const ifaceHits = hops.filter((h) =>
h.kind === "iface" && h.fromId === id && ifaceNorm(h.iface) === iface,
)
if (ifaceHits.length) return mergeDirected(ifaceHits, id)
return undefined
}
+60
View File
@@ -0,0 +1,60 @@
import {
type LucideIcon, LayoutDashboardIcon, ActivityIcon, ChartColumnIcon, MapIcon, HeartPulseIcon,
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon, UsersIcon, LockIcon,
} from "lucide-react"
export interface NavigationItem {
id: string
title: string
group: string
url: string
icon: LucideIcon
keywords: string[]
}
/** Shared page catalog for sidebar and keyboard search. */
export const navigationItems: NavigationItem[] = [
// Обзор
{ id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: LayoutDashboardIcon, keywords: ["главная","home","overview"] },
{ id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: ActivityIcon, keywords: ["bandwidth","traffic","клиенты","интерфейсы"] },
{ id: "statistics", title: "Статистика", group: "Обзор", url: "/statistics", icon: ChartColumnIcon, keywords: ["stats","отчёт","куб","страны","asn","ipfix"] },
{ id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: MapIcon, keywords: ["topology","топология","map"] },
{ id: "uptime", title: "Мониторинг", group: "Обзор", url: "/uptime", icon: HeartPulseIcon, keywords: ["ping","uptime","мониторинг","проверка"] },
// Данные
{ id: "domains", title: "Домены", group: "Данные", url: "/domains", icon: GlobeIcon, keywords: ["domain","dns","сайты"] },
{ id: "ip-ranges", title: "IP-диапазоны", group: "Данные", url: "/ip-ranges", icon: NetworkIcon, keywords: ["cidr","subnet","подсети","префиксы"] },
{ id: "asns", title: "ASN", group: "Данные", url: "/asns", icon: LayersIcon, keywords: ["autonomous system","автономная система"] },
{ id: "communities", title: "Communities", group: "Данные", url: "/communities", icon: TagIcon, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: ServerIcon, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "users", title: "Пользователи", group: "Управление", url: "/users", icon: UsersIcon, keywords: ["user","клиент","оператор","привязка","интерфейс"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: FilterIcon, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: RouteIcon, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: ShieldIcon, keywords: ["rules","правила","брандмауэр","acl"] },
{ id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: ShieldCheckIcon, keywords: ["vpn","tunnel","туннель","wg"] },
{ id: "ipsec", title: "IPsec / IKEv2", group: "Управление", url: "/ipsec", icon: LockIcon, keywords: ["vpn","ikev2","ipsec","сертификат","p12","клиент"] },
{ id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: CableIcon, keywords: ["gre","ipsec","tunnel","туннель"] },
{ id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: NetworkIcon, keywords: ["overlay","vni","vtep","l2"] },
{ id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: BoxIcon, keywords: ["docker","container","образ","image"] },
{ 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"] },
{ id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: ScanLineIcon, keywords: ["probe","ping","traceroute","диагностика"] },
{ id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: TerminalIcon, keywords: ["terminal","cli","ssh","консоль"] },
// Система
{ id: "mcp", title: "MCP", group: "Система", url: "/mcp", icon: ShieldCheckIcon, keywords: ["mcp", "ai", "нейросети", "агенты", "разрешения", "аудит"] },
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: BellIcon, keywords: ["alert","notification","уведомление"] },
{ id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: DatabaseIcon, keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] },
{ id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: BadgeCheckIcon, keywords: ["release","version","версия","changelog","релиз"] },
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: SettingsIcon, keywords: ["settings","config","конфигурация"] },
]
export const navigationGroups = [...new Set(navigationItems.map(item => item.group))]
.map(label => ({ label, items: navigationItems.filter(item => item.group === label) }))
+5
View File
@@ -0,0 +1,5 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {fitNetworkMapCamera} from "./network-map-camera"
test("camera fits off-world nodes and labels across aspect ratios",()=>{for(const h of [580,1600]){const points=[{x:-200,y:50},{x:1000,y:700}],c=fitNetworkMapCamera(points,1240,h);for(const p of points){assert.ok(p.x>=c.pan.x+119);assert.ok(p.x<=c.pan.x+1240/c.zoom-119);assert.ok(p.y>=c.pan.y+99);assert.ok(p.y<=c.pan.y+h/c.zoom-99)}}})
test("empty/invalid positions and maximum initial zoom",()=>{assert.deepEqual(fitNetworkMapCamera([],1240,580),{pan:{x:0,y:0},zoom:1});assert.deepEqual(fitNetworkMapCamera([{x:NaN,y:1}],1240,580),{pan:{x:0,y:0},zoom:1});assert.equal(fitNetworkMapCamera([{x:1,y:1}],1240,1600).zoom,3)})
+8
View File
@@ -0,0 +1,8 @@
export interface MapPoint {x:number;y:number}
export function fitNetworkMapCamera(points:readonly MapPoint[],width:number,height:number) {
const valid=points.filter(p=>Number.isFinite(p.x)&&Number.isFinite(p.y))
if(!valid.length||width<=0||height<=0)return {pan:{x:0,y:0},zoom:1}
const xs=valid.map(p=>p.x),ys=valid.map(p=>p.y),left=Math.min(...xs)-120,right=Math.max(...xs)+120,top=Math.min(...ys)-100,bottom=Math.max(...ys)+100
const zoom=Math.max(.2,Math.min(3,width/(right-left),height/(bottom-top)))
return {pan:{x:(left+right)/2-width/(2*zoom),y:(top+bottom)/2-height/(2*zoom)},zoom}
}
+904
View File
@@ -0,0 +1,904 @@
/**
* Раскладка узлов карты сети и синтетические WAN→JH рёбра по данным серверов (как в оптимизаторе).
*/
import type { GreTunnel } from "@/lib/data"
import type { Server, ServerType } from "@/lib/data"
import {
greOuterComparableIps,
normalizeGreEndpointAddr,
wanUplinkIpMatchesGreOuter,
} from "@/lib/gre-endpoint-resolve"
/** Неизвестные live метрики GRE; синтетические значения доступны только при demo=true. */
export interface TunnelProbe {
pingMs: number | null
dlMbps: number | null
ulMbps: number | null
}
export function greTunnelProbe(t: GreTunnel, demo = false): TunnelProbe {
if (!demo) return {pingMs:null,dlMbps:null,ulMbps:null}
if (t.status === "down" || !t.enabled)
return { pingMs: null, dlMbps: null, ulMbps: null }
const h = t.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
if (t.status === "degraded") {
return {
pingMs: 35 + (h % 80),
dlMbps: 40 + (h % 120),
ulMbps: 35 + (h % 100),
}
}
return {
pingMs: 6 + (h % 30),
dlMbps: 180 + (h % 520),
ulMbps: 120 + (h % 400),
}
}
const W = 1240
const H = 580
const MARGIN = 72
const SERVICE_COL_W = 150
export { SERVICE_COL_W }
/** Карточка конечного сервиса на карте (центр = позиция узла). */
export const MAP_SERVICE_NODE_W = 86
export const MAP_SERVICE_NODE_H = 58
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
export const NETWORK_MAP_PIPELINE_Y = 300
type NodeRole = "HR" | "JH" | "EN"
function roleOfServer(s: Server): NodeRole | null {
if (s.type === "home-router") return "HR"
if (s.type === "jump-host") return "JH"
if (s.type === "exit-node") return "EN"
return null
}
function roleLayer(role: NodeRole): number {
switch (role) {
case "HR": return 1
case "JH": return 2
case "EN": return 3
}
}
function layerOfServer(s: Server): number | null {
const r = roleOfServer(s)
return r ? roleLayer(r) : null
}
/**
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_W = W
export const NETWORK_MAP_H = H
export const NETWORK_MAP_LAYOUT_REVISION = 7
export interface WanJhEdge {
homeId: string
wanIdx: number
jhId: string
/** Сумма `latency` домашнего узла и JH из каталога (как в /servers); null если нет обоих замеров. */
pingMs: number | null
dlMbps: number
active: boolean
}
export interface NetworkMapSrvRes {
cpu: number | null
cpuHistory: number[]
ramUsed: number | null
ramTotal: number | null
hddUsed: number | null
hddTotal: number | null
uptimeSeconds: number | null
temp?: number
/** Live значения сняты с последнего опроса; отсутствующие поля и HDD неизвестны. */
measuredAt?: string | null
fromPoll?: boolean
}
/** Uptime из поля resource «1w2d3h4m5s» (как на бэкенде). */
export function parseRouterosUptimeToSeconds(raw: string | null | undefined): number {
if (!raw) return 0
let total = 0
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = Number.parseInt(m[1], 10)
if (!Number.isFinite(n)) continue
switch (m[2]) {
case "w":
total += n * 604800
break
case "d":
total += n * 86400
break
case "h":
total += n * 3600
break
case "m":
total += n * 60
break
case "s":
total += n
break
}
}
return total
}
/** Равномерно по отрезку [xMin, xMax] — несколько узлов в одной колонке. */
function spreadInSegment(count: number, index: number, xMin: number, xMax: number): number {
if (count <= 0) return (xMin + xMax) / 2
if (count === 1) return (xMin + xMax) / 2
return xMin + (index / (count - 1)) * (xMax - xMin)
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n))
}
function resolveLaneCollisions(
ids: string[],
nodePos: Record<string, { x: number; y: number }>,
minY: number,
maxY: number,
minGap: number,
): void {
if (ids.length <= 1) {
if (ids.length === 1) {
const id = ids[0]
const p = nodePos[id]
if (p) p.y = clamp(p.y, minY, maxY)
}
return
}
const sorted = ids
.map((id) => ({ id, y: nodePos[id]?.y ?? (minY + maxY) / 2 }))
.sort((a, b) => a.y - b.y)
const n = sorted.length
const maxSpan = maxY - minY
const gap = Math.min(minGap, maxSpan / (n - 1))
const center = sorted.reduce((acc, s) => acc + s.y, 0) / n
const span = gap * (n - 1)
const start = clamp(center - span / 2, minY, maxY - span)
for (let i = 0; i < n; i++) {
const p = nodePos[sorted[i].id]
if (!p) continue
p.y = start + i * gap
}
}
/** Детерминированные координаты узлов и «спутников» WAN под home-router.
*
* Важно: раскладка строго иерархическая по ролям:
* - HR (home-router) слева,
* - JH (jump-host) по центру,
* - EN (exit-node) справа.
*
* Это не force-layout: координаты полностью детерминированы по составу servers.
*/
function average(nums: number[]): number | null {
if (nums.length === 0) return null
return nums.reduce((acc, n) => acc + n, 0) / nums.length
}
function alignLayerCenterY(
ids: string[],
nodePos: Record<string, { x: number; y: number }>,
targetCenterY: number,
minY: number,
maxY: number,
): void {
if (ids.length === 0) return
const ys = ids
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const currentCenter = average(ys)
if (currentCenter == null) return
const delta = targetCenterY - currentCenter
for (const id of ids) {
const p = nodePos[id]
if (!p) continue
p.y = clamp(p.y + delta, minY, maxY)
}
}
type HierarchyLinks = {
hrToJh: Map<string, Set<string>>
jhToEn: Map<string, Set<string>>
hrToEn: Map<string, Set<string>>
}
function buildHierarchyLinks(
servers: Server[],
tunnels: GreTunnel[],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): HierarchyLinks {
const hrToJh = new Map<string, Set<string>>()
const jhToEn = new Map<string, Set<string>>()
const hrToEn = new Map<string, Set<string>>()
const byId = new Map(servers.map((s) => [s.id, s] as const))
function add(map: Map<string, Set<string>>, from: string, to: string) {
const set = map.get(from) ?? new Set<string>()
set.add(to)
map.set(from, set)
}
for (const t of tunnels) {
const a = byId.get(String(t.serverId))
const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b || a.id === b.id) continue
const la = layerOfServer(a)
const lb = layerOfServer(b)
if (la == null || lb == null || la === lb) continue
const from = la < lb ? a : b
const to = la < lb ? b : a
const fr = roleOfServer(from)
const tr = roleOfServer(to)
if (!fr || !tr) continue
if (fr === "HR" && tr === "JH") add(hrToJh, from.id, to.id)
else if (fr === "JH" && tr === "EN") add(jhToEn, from.id, to.id)
else if (fr === "HR" && tr === "EN") add(hrToEn, from.id, to.id)
}
return { hrToJh, jhToEn, hrToEn }
}
export function computeNetworkMapLayout(
servers: Server[],
tunnels: GreTunnel[] = [],
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): {
nodePos: Record<string, { x: number; y: number }>
wanSatPos: Record<string, { x: number; y: number }[]>
} {
const nodePos: Record<string, { x: number; y: number }> = {}
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
const span = W - 2 * MARGIN - SERVICE_COL_W
const laneGap = Math.min(44, span * 0.04)
const laneW = (span - 2 * laneGap) / 3
// Жёсткое позиционирование по иерархическим слоям:
// - HR: левая полоса (0–20%),
// - JH: центральная (40–60%),
// - EN: правая (80–100%).
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
const laneByType = new Map<ServerType, Server[]>()
const minNodeY = MARGIN + 70
const maxNodeY = H - 72
const laneMinGap = 110
const typeYOffset: Record<ServerType, number> = {
"home-router": -14,
"jump-host": 0,
"exit-node": 14,
}
const siteWeight = (s: Server): number => {
const typeBoost = s.type === "jump-host" ? 3 : s.type === "home-router" ? 2 : 1
const statusBoost = s.status === "online" ? 2 : s.status === "degraded" ? 1 : 0
return typeBoost + statusBoost
}
const siteNames = [
...new Set(
servers
.map((s) => s.site?.trim())
.filter((v): v is string => Boolean(v)),
),
]
siteNames.sort((a, b) => {
const wa = servers.filter((s) => s.site === a).reduce((acc, s) => acc + siteWeight(s), 0)
const wb = servers.filter((s) => s.site === b).reduce((acc, s) => acc + siteWeight(s), 0)
if (wb !== wa) return wb - wa
return a.localeCompare(b)
})
const siteY = new Map<string, number>()
siteNames.forEach((site, i) => {
siteY.set(site, spreadInSegment(siteNames.length, i, minNodeY, maxNodeY))
})
const fallbackY = (minNodeY + maxNodeY) / 2
let xLane = MARGIN
for (const type of laneOrder) {
const group = servers
.filter((s) => s.type === type)
.slice()
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
laneByType.set(type, group)
const xMin = xLane
const xMax = xLane + laneW
const laneCenterX = (xMin + xMax) / 2
group.forEach((s) => {
const baseline = siteY.get(s.site) ?? fallbackY
const siblings = group.filter((g) => g.site === s.site)
const sibIdx = siblings.findIndex((g) => g.id === s.id)
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
nodePos[s.id] = {
x: laneCenterX,
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
}
})
xLane += laneW + laneGap
}
const homes = laneByType.get("home-router") ?? []
const jhs = laneByType.get("jump-host") ?? []
const exits = laneByType.get("exit-node") ?? []
const links = buildHierarchyLinks(servers, tunnels, resolvedIpv4ByHost)
const hrParentsByJh = new Map<string, string[]>()
for (const [hrId, jhSet] of links.hrToJh.entries()) {
for (const jhId of jhSet) {
const arr = hrParentsByJh.get(jhId) ?? []
arr.push(hrId)
hrParentsByJh.set(jhId, arr)
}
}
// JH тянем по Y к HR-родителям, чтобы хабы не "плавали" от site-сортировки.
for (const jh of jhs) {
const parentIds = hrParentsByJh.get(jh.id) ?? []
const parentYs = parentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const target = average(parentYs)
if (target != null && nodePos[jh.id]) {
nodePos[jh.id]!.y = clamp(target, minNodeY, maxNodeY)
}
}
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
const jhParentsByEn = new Map<string, string[]>()
for (const [jhId, enSet] of links.jhToEn.entries()) {
for (const enId of enSet) {
const arr = jhParentsByEn.get(enId) ?? []
arr.push(jhId)
jhParentsByEn.set(enId, arr)
}
}
const hrParentsByEn = new Map<string, string[]>()
for (const [hrId, enSet] of links.hrToEn.entries()) {
for (const enId of enSet) {
const arr = hrParentsByEn.get(enId) ?? []
arr.push(hrId)
hrParentsByEn.set(enId, arr)
}
}
// EN вешаем на JH-родителей (или HR fallback), чтобы получать читаемую правую "leaf"-ветку.
for (const en of exits) {
const jhParentIds = jhParentsByEn.get(en.id) ?? []
const jhParentYs = jhParentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const jhTarget = average(jhParentYs)
if (jhTarget != null && nodePos[en.id]) {
nodePos[en.id]!.y = clamp(jhTarget, minNodeY, maxNodeY)
continue
}
const hrParentIds = hrParentsByEn.get(en.id) ?? []
const hrParentYs = hrParentIds
.map((id) => nodePos[id]?.y)
.filter((v): v is number => typeof v === "number")
const hrTarget = average(hrParentYs)
if (hrTarget != null && nodePos[en.id]) {
nodePos[en.id]!.y = clamp(hrTarget, minNodeY, maxNodeY)
}
}
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
// EN-слой выравниваем относительно центра HR-слоя:
// карта читается как "ingress слева -> leaf справа" на одной оси.
const hrCenterY = average(
homes
.map((s) => nodePos[s.id]?.y)
.filter((v): v is number => typeof v === "number"),
)
if (hrCenterY != null) {
alignLayerCenterY(exits.map((s) => s.id), nodePos, hrCenterY, minNodeY, maxNodeY)
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
}
const hr = homes
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
for (const r of hr) {
const wans = r.wanUplinks?.length ? r.wanUplinks : []
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
const n = wans.length
const satX = (base.x + jhColumnCenterX) / 2
const rowY = base.y
const maxV = H - 2 * MARGIN - 96
// Разносим WAN-аплинки по высоте заметнее для читаемости подписей/бейджей.
const preferredGap = 140
const vGap =
n <= 1 ? 0 : Math.min(preferredGap, maxV / Math.max(1, n - 1))
const positions: { x: number; y: number }[] = []
for (let i = 0; i < n; i++) {
const rawY = n === 1 ? rowY : rowY + (i - (n - 1) / 2) * vGap
const y = Math.max(MARGIN + 48, Math.min(H - 72, rawY))
positions.push({ x: satX, y })
}
wanSatPos[r.id] = positions
}
return { nodePos, wanSatPos }
}
/** Колонка конечных сервисов справа от EN. */
export function placeServiceNodes(
serviceIds: string[],
enPositions: Array<{ x: number; y: number }>,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
const n = serviceIds.length
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(centerY - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
})
return out
}
/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */
export function placeCountryServiceNodes(
serviceIds: string[],
parentPos: { x: number; y: number } | undefined,
): Record<string, { x: number; y: number }> {
const out: Record<string, { x: number; y: number }> = {}
if (serviceIds.length === 0 || !parentPos) return out
const minY = MARGIN + 70
const maxY = H - 72
const x = W - MARGIN - SERVICE_COL_W / 2
const n = serviceIds.length
const gap = Math.min(80, (maxY - minY) / Math.max(1, n))
const span = gap * (n - 1)
const start = clamp(parentPos.y - span / 2, minY, maxY - span)
serviceIds.forEach((id, i) => {
out[id] = { x, y: n === 1 ? clamp(parentPos.y, minY, maxY) : start + i * gap }
})
return out
}
/**
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
*/
function legPing(home: Server, jh: Server): number | null {
/** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
const hOk = home.status !== "offline"
const jOk = jh.status !== "offline"
if (!hOk || !jOk) return null
if (home.latency == null || jh.latency == null) return null
return Math.min(995, Math.round(home.latency + jh.latency))
}
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
const homes = servers.filter((s) => s.type === "home-router")
const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
if (homes.length === 0 || jhs.length === 0) return []
const edges: WanJhEdge[] = []
for (const home of homes) {
const wans = home.wanUplinks?.length ? home.wanUplinks : []
if (wans.length === 0) continue
for (let wanIdx = 0; wanIdx < wans.length; wanIdx++) {
const wan = wans[wanIdx]
for (const jh of jhs) {
edges.push({
homeId: home.id,
wanIdx,
jhId: jh.id,
pingMs: legPing(home, jh),
dlMbps: Math.max(1, wan.maxDl),
active: false,
})
}
}
}
const key = (e: WanJhEdge) => `${e.homeId}::${e.wanIdx}`
const score = (p: number | null) => (p == null ? Number.POSITIVE_INFINITY : p)
const best = new Map<string, WanJhEdge>()
for (const e of edges) {
const k = key(e)
const prev = best.get(k)
if (!prev || score(e.pingMs) < score(prev.pingMs)) best.set(k, e)
}
for (const e of edges) {
const winner = best.get(key(e))
e.active =
winner != null &&
winner.pingMs != null &&
e.jhId === winner.jhId &&
e.pingMs === winner.pingMs
}
return edges
}
/** Ресурсы API без синтетической истории и fallback; demo=true включает демонстрацию по id. */
export function buildServerResourceMap(servers: Server[], demo = false): Record<string, NetworkMapSrvRes> {
if (!demo) return Object.fromEntries(servers.map(s=>[s.id,{
cpu:s.cpuLoad ?? null,cpuHistory:[],
ramTotal:s.totalMemory==null?null:s.totalMemory/(1024*1024),
ramUsed:s.totalMemory==null||s.freeMemory==null?null:(s.totalMemory-s.freeMemory)/(1024*1024),
hddUsed:null,hddTotal:null,
uptimeSeconds:s.uptime && /^(?:\d+[wdhms])+$/.test(s.uptime)?parseRouterosUptimeToSeconds(s.uptime):null,
measuredAt:s.polledAt ?? null,fromPoll:true,
} satisfies NetworkMapSrvRes]))
return Object.fromEntries(
servers.map((s) => {
const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0)
const ramTotalDemo = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024
const hddTotalDemo = s.type === "home-router" ? 2048 : 16384
const ramPctDemo = 12 + (h % 72)
const hddPctDemo = 8 + (h % 75)
const hasPollRam =
s.cpuLoad != null &&
typeof s.totalMemory === "number" &&
s.totalMemory > 0 &&
typeof s.freeMemory === "number"
if (hasPollRam) {
const cpu = Math.max(0, Math.min(100, Math.round(s.cpuLoad!)))
const ramTotalMb = Math.max(1, Math.round(s.totalMemory! / (1024 * 1024)))
const usedBytes = Math.max(0, s.totalMemory! - s.freeMemory!)
const ramUsedMb = Math.min(ramTotalMb, Math.max(0, Math.round(usedBytes / (1024 * 1024))))
let uptimeSeconds = s.uptime ? parseRouterosUptimeToSeconds(s.uptime) : 0
if (uptimeSeconds <= 0 && s.uptime) {
uptimeSeconds =
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
}
if (uptimeSeconds <= 0) {
uptimeSeconds =
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60
}
const hddTotal = hddTotalDemo
const hddUsed = Math.round((hddTotal * hddPctDemo) / 100)
return [
s.id,
{
cpu,
cpuHistory: Array.from({ length: 32 }, (_, i) =>
Math.max(0, Math.min(100, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 6))),
),
ramUsed: ramUsedMb,
ramTotal: ramTotalMb,
hddUsed,
hddTotal,
uptimeSeconds,
fromPoll: true,
} satisfies NetworkMapSrvRes,
]
}
const cpu = 4 + (h % 68)
const ramTotal = ramTotalDemo
const hddTotal = hddTotalDemo
return [
s.id,
{
cpu,
cpuHistory: Array.from({ length: 32 }, (_, i) =>
Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))),
),
ramUsed: Math.round((ramTotal * ramPctDemo) / 100),
ramTotal,
hddUsed: Math.round((hddTotal * hddPctDemo) / 100),
hddTotal,
uptimeSeconds:
(1 + (h % 200)) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
fromPoll: false,
} satisfies NetworkMapSrvRes,
]
}),
)
}
// ── GRE: сопоставление remote-address с узлами (WAN ≠ API host в БД) ─────────
/** Нормализация IP/endpoint из RouterOS (CIDR, zone id). */
export function normalizeGreEndpoint(addr: string): string {
return normalizeGreEndpointAddr(addr)
}
function hostWithoutPort(host: string): string {
const h = host.trim()
if (h.startsWith("[")) {
const end = h.indexOf("]")
return end > 0 ? h.slice(1, end) : h
}
const lastColon = h.lastIndexOf(":")
if (lastColon > 0 && /^\d{1,5}$/.test(h.slice(lastColon + 1)))
return h.slice(0, lastColon)
return h
}
function normalizeIpNoMask(raw: string | null | undefined): string {
if (!raw) return ""
return normalizeGreEndpoint(raw.split("/")[0] ?? "")
}
function canonicalIpPairKey(aRaw: string, bRaw: string): string | null {
const a = normalizeGreEndpoint(aRaw)
const b = normalizeGreEndpoint(bRaw)
if (!a || !b || a === "0.0.0.0" || b === "0.0.0.0") return null
const p1 = a <= b ? a : b
const p2 = a <= b ? b : a
return `${p1}|${p2}`
}
function canonicalOuterPairKey(
t: GreTunnel,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string | null {
const la = greOuterComparableIps(t.localAddress, resolvedIpv4ByHost)
const ra = greOuterComparableIps(t.remoteAddress, resolvedIpv4ByHost)
const left = [...new Set(la.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
const right = [...new Set(ra.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
if (left.length === 0 || right.length === 0) return null
let best: string | null = null
for (const l of left) {
for (const r of right) {
const p1 = l <= r ? l : r
const p2 = l <= r ? r : l
const k = `${p1}|${p2}`
if (best == null || k < best) best = k
}
}
return best
}
/**
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
*
* Важно: при успешном DNS исходный FQDN всё равно участвует в поиске первым — иначе совпадение
* только по резолвнутому IP может выбрать «чужой» узел с тем же WAN, а speed-пробы перестанут
* сходиться с парой концов туннеля.
*/
export function findServerByGreRemote(
servers: Server[],
remoteAddress: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): Server | undefined {
const fallback = normalizeGreEndpoint(remoteAddress)
if (!fallback || fallback === "0.0.0.0") return undefined
const comparable = greOuterComparableIps(remoteAddress, resolvedIpv4ByHost)
const tryOrder: string[] = []
const seen = new Set<string>()
function pushCandidate(raw: string) {
const n = normalizeGreEndpoint(raw)
if (!n || n === "0.0.0.0" || seen.has(n)) return
seen.add(n)
tryOrder.push(n)
}
pushCandidate(fallback)
for (const ip of comparable) pushCandidate(ip)
for (const c of tryOrder) {
for (const s of servers) {
if (normalizeGreEndpoint(hostWithoutPort(s.host)) === c) return s
}
}
for (const c of tryOrder) {
for (const s of servers) {
for (const w of s.wanUplinks ?? []) {
if (normalizeGreEndpoint(w.ip) === c) return s
}
}
}
return undefined
}
/** Индекс WAN-аплинка Home Router по внешнему IP (GRE outer / remote-address). */
export function wanIndexMatchingOuterIp(
server: Server,
outerIp: string,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): number | null {
if (server.type !== "home-router") return null
if (!normalizeGreEndpoint(outerIp)) return null
const wans = server.wanUplinks ?? []
for (let i = 0; i < wans.length; i++) {
if (wanUplinkIpMatchesGreOuter(wans[i].ip, outerIp, resolvedIpv4ByHost)) return i
}
return null
}
/**
* Индекс WAN-спутника на стороне узла, где поднят GRE (совпадает с якорем «source» на карте).
* Для сопоставления ребра WAN→JH с GRE-бейджем на том же аплинке.
*/
export function greSourceWanIndexOnMap(
home: Server,
tunnel: GreTunnel,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): number | null {
if (home.type !== "home-router") return null
const wans = home.wanUplinks ?? []
if (wans.length === 0) return null
const la = tunnel.localAddress?.trim() ?? ""
let wanIdx: number | null = null
if (la === "0.0.0.0" || la === "") wanIdx = 0
else wanIdx = wanIndexMatchingOuterIp(home, tunnel.localAddress, resolvedIpv4ByHost)
if (wanIdx == null) wanIdx = 0
return wanIdx
}
function greEndpointAnchor(
server: Server,
tunnel: GreTunnel,
role: "source" | "peer",
nodePos: Record<string, { x: number; y: number }>,
wanSatPos: Record<string, { x: number; y: number }[]>,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): { x: number; y: number } {
const center = (): { x: number; y: number } =>
nodePos[server.id] ?? { x: W / 2, y: H / 2 }
if (server.type !== "home-router") return center()
const wans = server.wanUplinks ?? []
let wanIdx: number | null = null
if (role === "source") {
const la = tunnel.localAddress?.trim() ?? ""
if (la === "0.0.0.0" || la === "") wanIdx = wans.length > 0 ? 0 : null
else wanIdx = wanIndexMatchingOuterIp(server, tunnel.localAddress, resolvedIpv4ByHost)
if (wanIdx == null && wans.length > 0) wanIdx = 0
} else {
wanIdx = wanIndexMatchingOuterIp(server, tunnel.remoteAddress, resolvedIpv4ByHost)
}
if (wanIdx != null && wanSatPos[server.id]?.[wanIdx])
return wanSatPos[server.id][wanIdx]!
return center()
}
/** GRE на карте только между узлами каталога; у HR конец туннеля — у соответствующего WAN-спутника. */
export interface GreMapEdge {
tunnel: GreTunnel
from: { x: number; y: number }
to: { x: number; y: number }
fromServer: Server
toServer: Server
}
export function buildGreMapEdges(
tunnels: GreTunnel[],
servers: Server[],
nodePos: Record<string, { x: number; y: number }>,
wanSatPos: Record<string, { x: number; y: number }[]>,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): GreMapEdge[] {
const out: GreMapEdge[] = []
const seenByLink = new Map<string, number>()
const statusRank: Record<GreTunnel["status"], number> = {
up: 3,
degraded: 2,
down: 1,
}
function tunnelPriority(t: GreTunnel): number {
return (t.enabled ? 100 : 0) + (statusRank[t.status] ?? 0)
}
function canonicalLinkKey(t: GreTunnel, a: Server, b: Server): string {
const s1 = a.id <= b.id ? a.id : b.id
const s2 = a.id <= b.id ? b.id : a.id
// 1) Предпочитаем inner-пару: у directional A→B/B→A она одинакова по /30.
const inner = canonicalIpPairKey(
normalizeIpNoMask(t.localInnerIp),
normalizeIpNoMask(t.remoteInnerIp),
)
if (inner) return `${s1}|${s2}|inner:${inner}`
// 2) Иначе outer-пара с учетом DNS/FQDN→IPv4 сопоставления.
const outer = canonicalOuterPairKey(t, resolvedIpv4ByHost)
if (outer) return `${s1}|${s2}|outer:${outer}`
// 3) Крайний случай: чтобы не схлопнуть потенциально разные линki.
return `${s1}|${s2}|id:${t.id}`
}
for (const t of tunnels) {
const a = servers.find((s) => s.id === t.serverId)
const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b) continue
if (a.id === b.id) continue
const la = layerOfServer(a)
const lb = layerOfServer(b)
if (la == null || lb == null) continue
// EN→EN, JH→JH и т.п. на карте не показываем — только межуровневые рёбра.
if (la === lb) continue
// Строгий поток слева направо: HR → JH → EN.
// Если направление туннеля против иерархии — переворачиваем визуальное ребро.
let fromServer = a
let toServer = b
if (la > lb) {
fromServer = b
toServer = a
}
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
const edge: GreMapEdge = { tunnel: t, from, to, fromServer, toServer }
const key = canonicalLinkKey(t, fromServer, toServer)
const prevIdx = seenByLink.get(key)
if (prevIdx == null) {
seenByLink.set(key, out.length)
out.push(edge)
continue
}
const prev = out[prevIdx]
if (tunnelPriority(t) > tunnelPriority(prev.tunnel)) {
out[prevIdx] = edge
}
}
return out
}
/**
* Обрезать отрезок центр круга → центр прямоугольника по ободу круга и AABB карточки.
* Пунктир EN→сервис визуально упирается в край, как GRE под кругами узлов.
*/
export function clipSegmentCircleToRect(
x1: number,
y1: number,
r: number,
x2: number,
y2: number,
hw: number,
hh: number,
pad = 1.5,
): { x1: number; y1: number; x2: number; y2: number } {
const dx = x2 - x1
const dy = y2 - y1
const len = Math.hypot(dx, dy)
if (len < 1e-6) return { x1, y1, x2, y2 }
const ux = dx / len
const uy = dy / len
const sx = x1 + ux * (r + pad)
const sy = y1 + uy * (r + pad)
const absDx = Math.abs(dx)
const absDy = Math.abs(dy)
const u = Math.min(
absDx < 1e-9 ? 1 : (hw + pad) / absDx,
absDy < 1e-9 ? 1 : (hh + pad) / absDy,
)
const uu = Math.min(Math.max(u, 0), 0.48)
const ex = x2 - dx * uu
const ey = y2 - dy * uu
if ((ex - sx) * dx + (ey - sy) * dy <= 0) {
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
return { x1: mx - ux * 2, y1: my - uy * 2, x2: mx + ux * 2, y2: my + uy * 2 }
}
return { x1: sx, y1: sy, x2: ex, y2: ey }
}
+21
View File
@@ -0,0 +1,21 @@
import {test} from "node:test"
import assert from "node:assert/strict"
import {buildServerResourceMap,greTunnelProbe} from "./network-map-layout"
import {mergeGreMetricsWithSpeedProbe} from "./map-gre-speed-probe"
import {servers,greTunnels} from "./data"
test("live resources preserve zero, partial poll and unknowns without fake history",()=>{
const base={...servers[0],cpuLoad:undefined,totalMemory:undefined,freeMemory:undefined,uptime:undefined,polledAt:undefined}
const unknown=buildServerResourceMap([base])[base.id]
assert.deepEqual(unknown,{cpu:null,cpuHistory:[],ramTotal:null,ramUsed:null,hddUsed:null,hddTotal:null,uptimeSeconds:null,measuredAt:null,fromPoll:true})
const partial=buildServerResourceMap([{...base,cpuLoad:0,uptime:"0s",polledAt:"2020-01-01T00:00:00Z"}])[base.id]
assert.equal(partial.cpu,0);assert.equal(partial.uptimeSeconds,0);assert.equal(partial.measuredAt,"2020-01-01T00:00:00Z");assert.equal(partial.ramUsed,null)
const memory=buildServerResourceMap([{...base,totalMemory:1048576,freeMemory:1048576,uptime:"bad"}])[base.id]
assert.equal(memory.ramUsed,0);assert.equal(memory.ramTotal,1);assert.equal(memory.uptimeSeconds,null)
assert.ok(buildServerResourceMap([base],true)[base.id].cpuHistory.length>0)
})
test("live GRE does not hash metrics and probe values retain zero and fractions",()=>{
const fallback=greTunnelProbe(greTunnels[0]);assert.deepEqual(fallback,{pingMs:null,dlMbps:null,ulMbps:null})
assert.equal(greTunnelProbe(greTunnels[0],true).pingMs!=null,true)
const merged=mergeGreMetricsWithSpeedProbe({id:"p",srcServerId:"1",dstServerId:"2",lastPingRttMs:0,lastTxAvgMbps:0,lastRxAvgMbps:0.125},fallback)
assert.deepEqual(merged,{pingMs:0,dlMbps:0,ulMbps:0.125,hasSpeedMonitor:true})
})
+48
View File
@@ -0,0 +1,48 @@
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:[]})
assert.deepEqual(result.tunnels[0].ipsec,{secret:""})
})
test("network map requires complete envelopes and preserves zero/null",()=>{
const result=validateNetworkMapSnapshot([server],{tunnels:[],failures:[]},{probes:[]})
assert.equal(result.servers[0].latency,0);assert.equal(result.servers[0].status,null)
for(const args of [[null,{tunnels:[]},{probes:[]}],[[server],{}, {probes:[]}],[[server],{tunnels:[]},{}],[[server,server],{tunnels:[]},{probes:[]}],[[{...server,freeMemory:20,totalMemory:10}],{tunnels:[]},{probes:[]}],[[{...server,status:undefined}],{tunnels:[]},{probes:[]}]])assert.throws(()=>validateNetworkMapSnapshot(...args as [unknown,unknown,unknown]))
})
test("network map refuses partial GRE and foreign failure/probe contexts",()=>{
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"1",error:"offline"}]},{probes:[]}),/Неполный/)
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[],failures:[{serverId:"2",error:"offline"}]},{probes:[]}),/неизвестному/)
const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastTxAvgMbps:null,lastRxAvgMbps:null,lastPingRttMs:null,lastPingLossPct:null,lastPingAt:null}
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[]},{probes:[probe]}),/неизвестному/)
})
import {networkMapFlowSchema,validateNetworkMapDns} from "./network-map-response"
test("map Flow envelope retains zeros and refuses missing metadata and contradictory totals",()=>{
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
assert.equal(networkMapFlowSchema.parse(empty).totalBytes,0)
assert.equal(networkMapFlowSchema.parse(empty).live,false)
assert.throws(()=>networkMapFlowSchema.parse({...empty,totalBytes:undefined}))
assert.throws(()=>networkMapFlowSchema.parse({...empty,namedBytes:1}))
assert.throws(()=>networkMapFlowSchema.parse({...empty,rangeMinutes:10}))
})
test("map DNS matches exactly requested names and accepts explicit unresolved null",()=>{
assert.deepEqual(validateNetworkMapDns({results:{"router.example":null}},["router.example"]),{"router.example":null})
assert.throws(()=>validateNetworkMapDns({results:{}},["router.example"]))
assert.throws(()=>validateNetworkMapDns({results:{"foreign.example":"192.0.2.1"}},["router.example"]))
assert.throws(()=>validateNetworkMapDns({results:{"router.example":"not-ip"}},["router.example"]))
})
Loaded 100 of 3835 files, more files were not shown because too many files have changed in this diff. Show more