feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -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()}
|
||||
@@ -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()}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function backendInternalUrl(): string {
|
||||
return (process.env.BACKEND_INTERNAL_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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" }) ?? "", /Задача завершилась с ошибкой/)
|
||||
})
|
||||
@@ -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("; ") || "Задача завершилась с ошибкой")
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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("Время наблюдения истекло. Задача может продолжаться на сервере; повторите проверку этой задачи.")
|
||||
}
|
||||
@@ -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}
|
||||
})
|
||||
@@ -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,
|
||||
}]))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { CertificateDto, CertificateObserved } from "@mmapp/contracts/certificates"
|
||||
|
||||
/** Общий тип для демонстрационных строк и подтверждённых полей RouterOS. */
|
||||
export type CertificateView = CertificateDto | CertificateObserved
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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() : "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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():"")
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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}
|
||||
@@ -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"))
|
||||
})
|
||||
@@ -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("Неизвестный источник дашборда")
|
||||
}
|
||||
@@ -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}])))
|
||||
})
|
||||
@@ -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&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
||||
}
|
||||
|
||||
export { overview as dashboardProbeOverviewSchema }
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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}]}))
|
||||
})
|
||||
@@ -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&×tamps.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)
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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() : "")
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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() : "")
|
||||
}
|
||||
@@ -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: "задано",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: "Очистить фильтры",
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
]
|
||||
@@ -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}]}))
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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), /не подтверждено сервером/)
|
||||
}
|
||||
})
|
||||
@@ -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 не подтверждено сервером")
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)))]
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)}ч`
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 и остальные адреса не включены"))
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }))
|
||||
@@ -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)})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user