chore: synchronize pending app/backend updates and repository hygiene

Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-05-07 12:29:04 +07:00
1 parent bdb9b72fac
commit 5f31bb47fb
81 files changed
+11976 -1239

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import {
getAlertsMeta,
getTelegramBotToken,
getTelegramMessageThreadIdForApi,
getTelegramPublic,
listAlertGroups,
listAlertRules,
listMergedHistory,
replaceAlertsConfig,
formatAlertRuleTestTelegramText,
sendTelegramAlertMessage,
updateTelegramSettings,
} from "../services/alerts-service.js"
const AlertTypeSchema = z.enum([
"gre-tunnel",
"bgp-peer",
"bgp-prefix",
"gre-client",
"server",
"rtt",
"loss",
"traffic",
])
const AlertSeveritySchema = z.enum(["critical", "warning", "info"])
const AlertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"])
const RecoveryModeSchema = z.enum(["always", "never", "conditional"])
const AlertGroupBodySchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
combineMode: z.enum(["any", "all"]),
enabled: z.boolean(),
cooldownOverride: z.union([AlertCooldownSchema, z.null()]),
})
const AlertRuleBodySchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: AlertTypeSchema,
target: z.string().optional(),
targets: z.array(z.string().min(1)).optional(),
groupId: z.string().nullable().optional(),
/** Сводка; если переданы `conditions`, может дублировать первую строку */
condition: z.string().optional(),
/** Несколько условий (OR); приоритет над одной строкой `condition` */
conditions: z.array(z.string().min(1)).optional(),
severity: AlertSeveritySchema,
enabled: z.boolean(),
cooldown: AlertCooldownSchema,
/** Секунды подтверждения стабильности; 0 / null / не передано — выкл. */
confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
recoveryMode: RecoveryModeSchema.optional(),
recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
chatId: z.string(),
})
.refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), {
message: "Нужен хотя бы один объект: targets или target",
path: ["targets"],
})
.refine(
(r) =>
(r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()),
{ message: "Нужно хотя бы одно условие: conditions или condition", path: ["conditions"] },
)
const PutRulesSchema = z.object({
rules: z.array(AlertRuleBodySchema),
groups: z.array(AlertGroupBodySchema).optional(),
})
const PutTelegramSchema = z.object({
/** undefined — не менять; null или "" — очистить токен */
token: z.union([z.string(), z.null()]).optional(),
chatId: z.string().optional(),
/** undefined — не менять; null — сбросить (общий чат супергруппы) */
messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(),
})
const RulePreviewTelegramSchema = z.object({
name: z.string().min(1),
targets: z.array(z.string().min(1)).min(1),
conditionLine: z.string().min(1),
severity: AlertSeveritySchema,
cooldown: AlertCooldownSchema,
})
const TestTelegramSchema = z.object({
/** Если не передан — взять сохранённый токен из БД */
token: z.string().optional(),
chatId: z.string().optional(),
/** Если не передан — из БД; для проверки черновика до сохранения */
messageThreadId: z.coerce.number().int().positive().optional(),
/** Если задан — вместо короткого текста отправляется предпросмотр правила (создание / правка). */
rulePreview: RulePreviewTelegramSchema.optional(),
})
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/alerts", async (_req, reply) => {
const tg = getTelegramPublic()
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
return reply.send({
telegram: { ...tg, connected },
groups: listAlertGroups(),
rules: listAlertRules(),
history: listMergedHistory(),
meta: getAlertsMeta(),
})
})
app.put("/alerts/rules", async (req, reply) => {
const parsed = PutRulesSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const groups =
parsed.data.groups ??
listAlertGroups().map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride,
}))
const rules = parsed.data.rules.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
target: r.target,
targets: r.targets,
groupId: r.groupId ?? null,
condition: r.condition,
conditions:
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: r.condition?.trim()
? [r.condition.trim()]
: [],
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: r.confirmStabilitySec ?? null,
recoveryMode: r.recoveryMode ?? "always",
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
chatId: r.chatId,
}))
replaceAlertsConfig({ groups, rules })
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() })
})
app.put("/alerts/telegram", async (req, reply) => {
const parsed = PutTelegramSchema.safeParse(req.body)
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const next = updateTelegramSettings({
token: parsed.data.token,
chatId: parsed.data.chatId,
messageThreadId: parsed.data.messageThreadId,
})
const connected = next.tokenConfigured && Boolean(next.chatId?.trim())
return reply.send({ ok: true, telegram: { ...next, connected } })
})
app.post("/alerts/telegram/test", async (req, reply) => {
const parsed = TestTelegramSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const fromDb = getTelegramBotToken()
const token = (parsed.data.token?.trim() || fromDb).trim()
const pub = getTelegramPublic()
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
if (!token) {
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
}
if (!chatId) {
return reply.status(400).send({ error: "Не задан Chat ID" })
}
const threadId =
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
? parsed.data.messageThreadId
: getTelegramMessageThreadIdForApi()
const text = parsed.data.rulePreview
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
: "MikroTik Manager — тест оповещений (Telegram)."
const send = await sendTelegramAlertMessage({
text,
token: parsed.data.token?.trim() || undefined,
chatId,
messageThreadId: threadId,
})
if (!send.ok) {
return reply.status(502).send({ ok: false, error: send.error })
}
return reply.send({ ok: true })
})
}
export default alertsRoutes
+4 -148
View File
@@ -2,154 +2,10 @@ import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { parseBgpSessions } from "../services/bgp-parse-sessions.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import type { RosBgpSession, BgpSessionRead } from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g)
for (const m of matches) {
const n = parseInt(m[1])
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
// ms ignored for seconds precision
}
}
return total
}
/**
* Map RouterOS 7.x short capability codes to human-readable names.
* Both local and remote capability strings are comma-separated short codes.
*/
function parseCapabilities(capStr: string | undefined): string[] {
if (!capStr) return []
const codeMap: Record<string, string> = {
mp: "MP-BGP",
rr: "Route Refresh",
gr: "Graceful Restart",
as4: "4-byte-AS",
enhe: "Extended Next-Hop",
role: "BGP Role",
err: "Extended Route Refresh",
llgr: "Long-Lived GR",
"add-path": "ADD-PATH",
}
return capStr.split(",")
.map(c => c.trim().toLowerCase())
.filter(Boolean)
.map(c => codeMap[c] ?? c)
.filter((v, i, a) => a.indexOf(v) === i) // deduplicate
}
/**
* Determine BGP session state from a RouterOS 7.x session object.
*
* RouterOS 7.x does NOT include a "state" field.
* Instead: "established": "true" means the session is up.
* For non-established sessions the "state" field may appear (RouterOS 6.x compat),
* or we fall back by checking other indicators.
*/
function detectState(s: RosBgpSession): string {
// RouterOS 7.x: explicit established flag
if (s["established"] === "true") return "Established"
// RouterOS 6.x / some 7.x builds: "state" field
if (s["state"]) {
const map: Record<string, string> = {
established: "Established",
active: "Active",
idle: "Idle",
connect: "Connect",
opensent: "OpenSent",
openconfirm: "OpenConfirm",
"open sent": "OpenSent",
"open confirm": "OpenConfirm",
}
const key = s["state"].trim().toLowerCase()
if (map[key]) return map[key]
// partial match
for (const [k, v] of Object.entries(map)) {
if (key.startsWith(k)) return v
}
return key.charAt(0).toUpperCase() + key.slice(1)
}
// Has uptime but no established flag → assume degraded Active
if (s["uptime"]) return "Active"
return "Idle"
}
function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] {
return raw.map((s, idx) => {
const remoteAs = parseInt(s["remote.as"] ?? "0") || 0
const localAs = parseInt(s["local.as"] ?? "0") || 0
const type: "eBGP" | "iBGP" = (localAs > 0 && localAs === remoteAs) ? "iBGP" : "eBGP"
// Capabilities: prefer local caps (what we negotiated); merge with remote
const localCaps = parseCapabilities(s["local.capabilities"])
const remoteCaps = parseCapabilities(s["remote.capabilities"])
// Old-style boolean capability fields (RouterOS 6.x)
const legacyCaps: string[] = []
if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh")
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])]
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
const holdTime = parseDuration(s["hold-time"])
|| parseInt(s["active-holdtime"] ?? "0")
|| 90
// Keepalive: may have "s" suffix
const keepalive = parseDuration(s["keepalive-time"])
|| 30
// Message counts: RouterOS 7 uses "remote.messages" / "local.messages"
const inputMessages = parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0
const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0
const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "")
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: s["name"] ?? peerIp,
peerIp,
remoteAs,
localAs,
localId: s["local.id"] ?? "",
remoteId: s["remote.id"] ?? "",
state: detectState(s),
type,
uptime: s["uptime"] || null,
holdTime,
keepalive,
prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0,
prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0,
inputMessages,
outputMessages,
capabilities: caps,
lastError: s["last-notification"] || null,
}
})
}
import type { BgpSessionRead } from "../types/server.js"
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -176,7 +32,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return parseSessions(server, raw)
return parseBgpSessions(server, raw)
} catch {
return []
}
@@ -198,7 +54,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return reply.send(parseSessions(server, raw))
return reply.send(parseBgpSessions(server, raw))
} catch {
return reply.send([])
}
+97 -20
View File
@@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { db } from "../db/index.js"
import { evobgpSettings } from "../db/schema.js"
import { evobgpSettings, filterRules } from "../db/schema.js"
const PutEvobgpSchema = z.object({
baseUrl: z.string().optional(),
@@ -48,7 +48,14 @@ interface EvoCatalogRaw {
asns: {
items: Array<{
module_id: string
entry: { id: string; asn: number; community_id?: string | null }
entry: {
id: string
asn: number
community_id?: string | null
asn_name?: string | null
prefix_count?: number | null
asn_resolved_at?: string | null
}
}>
}
ip_ranges: {
@@ -62,6 +69,60 @@ interface EvoCatalogRaw {
}
}
/**
* По UUID community в каталоге EvoBGP: сумма «маршрутов» —
* 1 на домен, 1 на CIDR в ip_ranges, prefix_count на ASN.
*/
function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, number> {
const routesPerComm = new Map<string, number>()
function bump(commId: string, routeAdd: number) {
routesPerComm.set(commId, (routesPerComm.get(commId) ?? 0) + routeAdd)
}
for (const rowItem of catalog.domains?.items ?? []) {
const cid = rowItem.entry.community_id
if (cid) bump(cid, 1)
}
for (const rowItem of catalog.ip_ranges?.items ?? []) {
const cid = rowItem.entry.community_id
if (cid) bump(cid, 1)
}
for (const rowItem of catalog.asns?.items ?? []) {
const cid = rowItem.entry.community_id
if (!cid) continue
const pc = rowItem.entry.prefix_count
const n = typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0
bump(cid, n)
}
return routesPerComm
}
/**
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
* Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`).
*/
function distinctServersByFilterCommunityValue(): Map<string, number> {
const rows = db
.select({ serverId: filterRules.serverId, community: filterRules.community })
.from(filterRules)
.all()
const byVal = new Map<string, Set<number>>()
for (const r of rows) {
const v = (r.community ?? "").trim()
if (!v) continue
let set = byVal.get(v)
if (!set) {
set = new Set()
byVal.set(v, set)
}
set.add(r.serverId)
}
const out = new Map<string, number>()
for (const [v, set] of byVal) out.set(v, set.size)
return out
}
function problemMessage(text: string, status: number): string {
try {
const j = JSON.parse(text) as { detail?: string; title?: string }
@@ -236,32 +297,48 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
})
const asns = (catalog.asns?.items ?? []).map((rowItem) => {
const mod = modMap.get(rowItem.module_id)
const cid = rowItem.entry.community_id
const e = rowItem.entry
const name = (e.asn_name ?? "").trim()
const pc = e.prefix_count
const prefixes =
typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0
let updated = fmtTime
const rawResolved = (e.asn_resolved_at ?? "").trim()
if (rawResolved) {
const d = new Date(rawResolved)
if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU")
}
return {
id: `${rowItem.module_id}:${rowItem.entry.id}`,
asn: `AS${rowItem.entry.asn}`,
org: "—",
id: `${rowItem.module_id}:${e.id}`,
asn: `AS${e.asn}`,
org: name || "—",
country: "—",
prefixes: 0,
prefixes,
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
updated: fmtTime,
updated,
enabled: true,
}
})
const communities = commItems.map((c) => ({
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: 0,
prefixCount: 0,
action: "permit" as const,
enabled: true,
}))
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
const serversByCommunityStr = distinctServersByFilterCommunityValue()
const communities = commItems.map((c) => {
const valueStr = (c.community ?? "").trim()
return {
id: c.id,
value: c.community ?? c.id,
name: c.title ?? c.community ?? c.id,
description: "",
type: "custom" as const,
filterIds: [] as string[],
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
prefixCount: routeCountsByCommUuid.get(c.id) ?? 0,
action: "permit" as const,
enabled: true,
}
})
return reply.send({
fetchedAt,
+49 -25
View File
@@ -3,18 +3,10 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
type ServerRow = typeof servers.$inferSelect
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const fromLegacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!fromLegacy) return null
const parsed = Number.parseInt(fromLegacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
interface RosGre {
".id"?: string
name?: string
@@ -95,23 +87,55 @@ function parseInnerIps(rule: string | undefined): { localInnerIp: string; remote
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
const text = raw.rule ?? ""
// RouterOS rule formats vary across versions/config styles:
// - bgp-communities.has("AS:NNN")
// - bgp-communities includes AS:NNN
const communities = [
...text.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi),
...text.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi),
].map(m => m[1]).filter(Boolean)
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
const out: ApiFilterRule[] = []
const extractCommunities = (src: string): string[] => {
const communities = [
...src.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi),
...src.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi),
].map(m => m[1]).filter(Boolean)
return [...new Set(communities)]
}
// Парсим по веткам if/else if, чтобы action применялся к "своим" communities.
const branches = [...text.matchAll(/(?:if|else\s+if)\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/gi)]
if (branches.length > 0) {
for (const b of branches) {
const condition = b[1] ?? ""
const body = b[2] ?? ""
const comms = extractCommunities(condition)
if (comms.length === 0) continue
const isBlackhole =
/set\s+type\s+blackhole/i.test(body) ||
/set\s+blackhole\s+(?:yes|true)/i.test(body)
const gwToken = body.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = body.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
for (const community of comms) {
out.push({
id: `${raw[".id"] ?? "live"}-${out.length}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: inlineComment || raw.comment?.trim() || "",
})
}
}
if (out.length > 0) return out
}
// Fallback для старого/плоского синтаксиса без явных блоков.
const communities = extractCommunities(text)
if (communities.length === 0) return []
const uniqueCommunities = [...new Set(communities)]
const isBlackhole = /set\s+type\s+blackhole/i.test(text)
const isBlackhole =
/set\s+type\s+blackhole/i.test(text) ||
/set\s+blackhole\s+(?:yes|true)/i.test(text)
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
return uniqueCommunities.map((community, idx) => ({
return communities.map((community, idx) => ({
id: `${raw[".id"] ?? "live"}-${idx}`,
community,
action: isBlackhole ? "blackhole" : "route",
@@ -331,7 +355,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
app.get("/filters/router-compare", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
const serverId = parseDbServerId(q.serverId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
@@ -369,7 +393,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
app.get("/filters/gre-tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseServerId(q.serverId)
const sid = parseDbServerId(q.serverId)
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
if (sid !== null) {
@@ -423,7 +447,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/filters/sync/from-router", async (_req, reply) => {
const body = _req.body as { serverId?: string | number } | undefined
const rawServerId = body?.serverId
const serverId = parseServerId(rawServerId)
const serverId = parseDbServerId(rawServerId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
+400 -2
View File
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { and, desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import type {
@@ -9,8 +9,15 @@ import type {
RosBfdSession,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
} from "../types/server.js"
import { z } from "zod"
type ServerRow = typeof servers.$inferSelect
type TrafficSampleRow = typeof trafficSamples.$inferSelect
type SpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
const OspfOptimizeBodySchema = z.object({
pingWeight: z.number().int().min(0).max(100).default(60),
})
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -193,6 +200,328 @@ function parseInstances(
}))
}
function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
const w = pingWeight / 100
return Math.round(w * pingScore + (1 - w) * speedScore)
}
function getLatestSnapshotLatencyMs(serverId: number): number {
const latest = db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
}
function latestTrafficByInterface(serverId: number): Map<string, TrafficSampleRow> {
const rows = db
.select()
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
const map = new Map<string, TrafficSampleRow>()
for (const row of rows) {
const prev = map.get(row.interfaceName)
if (!prev || row.sampledAt > prev.sampledAt) map.set(row.interfaceName, row)
}
return map
}
function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number; ulMbps: number } {
if (!row) return { dlMbps: 10, ulMbps: 10 }
const dl = Math.max(1, Math.round((row.rxBps ?? 0) / 1_000_000))
const ul = Math.max(1, Math.round((row.txBps ?? 0) / 1_000_000))
return { dlMbps: dl, ulMbps: ul }
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
function parsePingTimeMs(raw: string | undefined): number | null {
if (!raw) return null
const s = String(raw).trim().toLowerCase().replace(",", ".")
const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/)
if (us) return Number.parseFloat(us[1]) / 1000
const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/)
if (ms) return Number.parseFloat(ms[1])
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
if (sec) return Number.parseFloat(sec[1]) * 1000
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
if (clock) {
const h = Number.parseFloat(clock[1])
const m = Number.parseFloat(clock[2])
const sc = Number.parseFloat(clock[3])
return (h * 3600 + m * 60 + sc) * 1000
}
return null
}
function avgPingMsFromResults(rows: Array<{ time?: string; status?: string }>): number | null {
const ok = rows.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout")
const vals = ok
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
if (!vals.length) return null
return Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length))
}
async function measurePingByInterface(
client: MikrotikClient,
ifaceName: string,
targetAddr: string | undefined,
fallbackMs: number,
): Promise<number> {
if (!targetAddr) return fallbackMs
try {
const rows = await client.ping(targetAddr, 2, ifaceName, { interval: "0.2s" })
return avgPingMsFromResults(rows) ?? fallbackMs
} catch {
return fallbackMs
}
}
type OspfRankedInterface = {
id: string
interface: string
currentCost: number
pingMs: number
dlMbps: number
ulMbps: number
score: number
optimalCost: number
}
async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise<OspfRankedInterface[]> {
const { ifaceTemplates, areas, instances, neighbors } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface))
const localLatencyMs = getLatestSnapshotLatencyMs(server.id)
const latestTraffic = latestTrafficByInterface(server.id)
// 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer).
const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0))
async function buildRouterIdToServerMap(needed: Set<string>): Promise<Map<string, ServerRow["id"]>> {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const out = new Map<string, ServerRow["id"]>()
const neededLeft = new Set(needed)
for (const s of enabledServers) {
if (neededLeft.size === 0) break
try {
const c = MikrotikClient.fromServer(s)
const inst = await c.getOspfInstances()
for (const i of inst) {
const rid = String(i["router-id"] ?? "").trim()
if (!rid) continue
if (neededLeft.has(rid)) {
out.set(rid, s.id)
neededLeft.delete(rid)
}
}
} catch {
// ignore per-server errors, preview should still work
}
}
return out
}
const routerIdToServerId = await buildRouterIdToServerMap(neededRouterIds)
// 2) Сначала выбираем remote serverId для каждого OSPF интерфейса (по neighbor router-id).
function pickBestNeighborForInterface(iface: string) {
const cand = neighbors.filter((n) => n.interface === iface)
if (!cand.length) return null
const rank = (st: string) => (st === "Full" ? 100 : st === "2-Way" ? 60 : 30)
return cand.sort((a, b) => rank(b.state) - rank(a.state))[0] ?? null
}
const remoteServerIds = new Set<number>()
const remoteForIface = new Map<string, number>()
for (const iface of editable) {
const n = pickBestNeighborForInterface(iface.interface)
if (!n) continue
const remoteServerId = routerIdToServerId.get(String(n["router-id"] ?? "").trim())
if (remoteServerId != null) {
remoteServerIds.add(remoteServerId)
remoteForIface.set(iface.interface, remoteServerId)
}
}
// 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты).
const allSourceProbes = db
.select()
.from(uptimeSpeedProbes)
.where(eq(uptimeSpeedProbes.srcServerId, server.id))
.all()
.filter((r: SpeedProbeRow) => r.enabled !== false)
const speedProbesByDest = new Map<number, SpeedProbeRow[]>()
for (const p of allSourceProbes) {
const arr = speedProbesByDest.get(p.dstServerId) ?? []
arr.push(p)
speedProbesByDest.set(p.dstServerId, arr)
}
function normIface(s: string) {
return s.trim().toLowerCase()
}
function pickBestSpeedProbe(probes: SpeedProbeRow[], ifaceName: string): SpeedProbeRow | undefined {
if (probes.length === 0) return undefined
const scoreProbeForIface = (p: SpeedProbeRow, targetIface: string): number => {
const got = normIface(p.srcInterface)
const target = normIface(targetIface)
// Сверяемся с /gre: если у пробы есть srcInterface и у OSPF iface есть имя,
// допускаем только точное совпадение интерфейса.
if (got && target && got !== target) return -1
const ifaceScore =
got && target && got === target ? 100
: (!got && target) ? 35
: (!target && got) ? 15
: 5
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
return ifaceScore
+ (p.lastPingRttMs != null ? 25 : 0)
+ (p.lastTxAvgMbps != null ? 12 : 0)
+ (p.lastRxAvgMbps != null ? 12 : 0)
+ freshness
}
let best: SpeedProbeRow | undefined
let bestScore = -1
for (const p of probes) {
const s = scoreProbeForIface(p, ifaceName)
if (s > bestScore) {
bestScore = s
best = p
}
}
return best
}
function assignProbesUniquely(
ifaceNames: string[],
probes: SpeedProbeRow[],
): Map<string, SpeedProbeRow> {
const assigned = new Map<string, SpeedProbeRow>()
if (ifaceNames.length === 0 || probes.length === 0) return assigned
const scoreProbeForIface = (p: SpeedProbeRow, ifaceName: string): number => {
const got = normIface(p.srcInterface)
const target = normIface(ifaceName)
if (got && target && got !== target) return -1
const ifaceScore =
got && target && got === target ? 100
: (!got && target) ? 35
: (!target && got) ? 15
: 5
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
return ifaceScore
+ (p.lastPingRttMs != null ? 25 : 0)
+ (p.lastTxAvgMbps != null ? 12 : 0)
+ (p.lastRxAvgMbps != null ? 12 : 0)
+ freshness
}
const leftIfaces = new Set(ifaceNames)
const usedProbeIds = new Set<string>()
while (leftIfaces.size > 0) {
let bestIface: string | null = null
let bestProbe: SpeedProbeRow | null = null
let bestScore = -1
for (const iface of leftIfaces) {
for (const p of probes) {
if (usedProbeIds.has(p.id)) continue
const s = scoreProbeForIface(p, iface)
if (s > bestScore) {
bestScore = s
bestIface = iface
bestProbe = p
}
}
}
if (!bestIface || !bestProbe) break
assigned.set(bestIface, bestProbe)
leftIfaces.delete(bestIface)
usedProbeIds.add(bestProbe.id)
}
// Если проб меньше чем интерфейсов — добираем лучшими совпадениями (reuse допустим).
for (const iface of leftIfaces) {
const fallback = pickBestSpeedProbe(probes, iface)
if (fallback) assigned.set(iface, fallback)
}
return assigned
}
const assignedProbeByIface = new Map<string, SpeedProbeRow>()
const ifacesByDst = new Map<number | null, string[]>()
for (const iface of editable) {
const dst = remoteForIface.get(iface.interface) ?? null
const arr = ifacesByDst.get(dst) ?? []
arr.push(iface.interface)
ifacesByDst.set(dst, arr)
}
for (const [dst, ifaceList] of ifacesByDst) {
const probesByDst = dst != null ? speedProbesByDest.get(dst) ?? [] : []
const pool = probesByDst.length > 0 ? probesByDst : allSourceProbes
const assigned = assignProbesUniquely(ifaceList, pool)
for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe)
}
return editable
.map((iface) => {
const t = latestTraffic.get(iface.interface)
const speed = normalizeSpeedMbps(t)
const dstServerId = remoteForIface.get(iface.interface)
const bestProbe = assignedProbeByIface.get(iface.interface)
const pingMs =
bestProbe?.lastPingRttMs != null
? Math.max(1, Math.round(bestProbe.lastPingRttMs))
: dstServerId != null
? Math.min(995, Math.round(localLatencyMs + getLatestSnapshotLatencyMs(dstServerId)))
: localLatencyMs
const dlMbps =
bestProbe?.lastTxAvgMbps != null
? Math.max(1, Math.round(bestProbe.lastTxAvgMbps))
: speed.dlMbps
const ulMbps =
bestProbe?.lastRxAvgMbps != null
? Math.max(1, Math.round(bestProbe.lastRxAvgMbps))
: speed.ulMbps
return {
id: iface.id,
interface: iface.interface,
currentCost: iface.cost,
pingMs,
dlMbps,
ulMbps,
score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight),
optimalCost: 0,
}
})
.sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface))
.map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 }))
}
// ── route plugin ──────────────────────────────────────────────────────────────
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -322,6 +651,75 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.status(500).send({ error: String(err) })
}
})
// POST /api/servers/:id/ospf/optimize — compute + apply optimal OSPF cost for active templates
app.post(
"/servers/:id/ospf/optimize/preview",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const pingWeight = req.body.pingWeight
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
return reply.send({
serverId: server.id,
serverName: server.name || server.host,
pingWeight,
interfacesTotal: ranked.length,
changedCount: changed.length,
unchangedCount: ranked.length - changed.length,
interfaces: ranked,
changes: changed,
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
},
)
app.post(
"/servers/:id/ospf/optimize",
{ schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } },
async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const pingWeight = req.body.pingWeight
const ranked = await buildOspfOptimizationPlan(server, pingWeight)
const client = MikrotikClient.fromServer(server)
const changed = ranked.filter((r) => r.currentCost !== r.optimalCost)
const applied: Array<{ interface: string; from: number; to: number }> = []
for (const item of changed) {
await client.setOspfInterfaceTemplateCost(item.id, item.optimalCost)
applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost })
}
return reply.send({
serverId: server.id,
serverName: server.name || server.host,
pingWeight,
optimizedCount: changed.length,
applied,
interfaces: ranked,
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
},
)
}
export default ospfRoutes
+6 -14
View File
@@ -3,6 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
type ServerRow = typeof servers.$inferSelect
@@ -46,15 +47,6 @@ interface RecursiveRouteDto {
disabled: boolean
}
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const legacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!legacy) return null
const parsed = Number.parseInt(legacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
@@ -150,7 +142,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -180,7 +172,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -189,7 +181,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseServerId(body.serverId)
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
@@ -199,7 +191,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = db
.select().from(servers)
@@ -235,7 +227,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
const serverId = parseDbServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
+53
View File
@@ -0,0 +1,53 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
getSchedulerStatus,
JOB_KEYS,
listSchedulerRuns,
refreshScheduler,
runSchedulerJobNow,
} from "../services/scheduler.js"
function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null {
const s = String(raw ?? "").trim()
return (JOB_KEYS as readonly string[]).includes(s) ? (s as typeof JOB_KEYS[number]) : null
}
const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/scheduler/status", async (_req, reply) => {
return reply.send(getSchedulerStatus())
})
app.get("/scheduler/runs", async (req, reply) => {
const q = req.query as { jobKey?: string; limit?: string; offset?: string }
const jobKey = q.jobKey ? parseJobKey(q.jobKey) : undefined
if (q.jobKey && jobKey == null) {
return reply.status(400).send({ error: "Некорректный jobKey" })
}
const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50))
const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0)
return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
})
app.post("/scheduler/refresh", async (_req, reply) => {
refreshScheduler()
return reply.send({ ok: true, status: getSchedulerStatus() })
})
app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => {
const jobKey = parseJobKey(String((req.params as { jobKey?: string }).jobKey ?? ""))
if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" })
try {
await runSchedulerJobNow(jobKey)
return reply.send({ ok: true, status: getSchedulerStatus() })
} catch (e) {
const statusCode = (e as Error & { statusCode?: number }).statusCode
if (statusCode === 409) {
return reply.status(409).send({ error: "Задача уже выполняется" })
}
const msg = e instanceof Error ? e.message : String(e)
return reply.status(500).send({ error: msg })
}
})
}
export default schedulerRoutes
+62
View File
@@ -0,0 +1,62 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
import {
collectServersRestPingOnce,
getServersApiPingSettings,
getServersRestPingCollectorState,
updateServersApiPingSettings,
} from "../services/servers-rest-ping-collector.js"
const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/servers-api-ping/settings", async (_req, reply) => {
const settings = getServersApiPingSettings()
const state = getServersRestPingCollectorState()
return reply.send({
enabled: settings.enabled,
intervalSec: settings.intervalSec,
lastCollectedAt: settings.lastCollectedAt ?? null,
lastDurationMs: settings.lastDurationMs ?? null,
lastError: settings.lastError || null,
collectorRunning: state.running,
})
})
app.put("/servers-api-ping/settings", async (req, reply) => {
const body = req.body as {
enabled?: boolean
intervalSec?: number | string
}
const intervalSec =
body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120)
const updated = updateServersApiPingSettings({
enabled: body.enabled,
intervalSec,
})
refreshScheduler()
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
},
})
})
app.post("/servers-api-ping/collect-now", async (_req, reply) => {
await collectServersRestPingOnce()
scheduleAlertEngineAfterDataCollectors()
const row = getServersApiPingSettings()
return reply.send({
ok: true,
lastCollectedAt: row.lastCollectedAt ?? null,
lastDurationMs: row.lastDurationMs ?? null,
lastError: row.lastError || null,
})
})
}
export default serversApiPingRoutes
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import {
filterRules,
recursiveRoutes,
servers,
uptimeProbes,
uptimeSpeedProbes,
} from "../db/schema.js"
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/sidebar-counts", async (_req, reply) => {
const [
serversTotal,
filterRulesTotal,
uptimeProbesTotal,
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
] = [
db.select().from(servers).all().length,
db.select().from(filterRules).all().length,
db.select().from(uptimeProbes).all().length,
db.select().from(uptimeSpeedProbes).all().length,
db.select().from(recursiveRoutes).all().length,
]
return reply.send({
servers: serversTotal,
filterRules: filterRulesTotal,
uptimeProbes: uptimeProbesTotal,
uptimeSpeedProbes: uptimeSpeedProbesTotal,
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal,
})
})
}
export default sidebarCountsRoutes
+4
View File
@@ -9,6 +9,8 @@ import {
readServerSamplesInRange,
updateTrafficSettings,
} from "../services/traffic-collector.js"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
@@ -189,6 +191,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
intervalSec,
retentionDays,
})
refreshScheduler()
return reply.send({
ok: true,
settings: {
@@ -204,6 +207,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/traffic/collect-now", async (_req, reply) => {
await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors()
const updated = getTrafficSettings()
return reply.send({
ok: true,
+125 -245
View File
@@ -3,10 +3,11 @@ import { asc, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpAddress } from "../types/server.js"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler, getSchedulerStatus } from "../services/scheduler.js"
import {
collectPingForProbeIds,
collectUptimeOnce,
parsePing,
readProbeRows,
readProbeSamplesSince,
readResourceSamplesSince,
@@ -15,6 +16,7 @@ import {
updateProbeShowOnDashboard,
updateUptimeSettings,
} from "../services/uptime-collector.js"
import { recordSpeedTestFailure, runBandwidthSpeedTest } from "../services/uptime-speed-service.js"
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "1h").toLowerCase()) {
@@ -34,69 +36,33 @@ function toSeries(values: number[], target = 40): number[] {
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */
type ResourceSampleRow = ReturnType<typeof readResourceSamplesSince>[number]
/**
* Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid,
* среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP).
* Последний сэмпл по времени может быть offline после сбоя опроса — тогда UI «пустой».
* Берём последний успешный (online + RAM), только если он не слишком старый относительно последней попытки,
* иначе снова показываем устаревший uptime/метрики как раньше.
*/
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
const name = iface.trim()
const candidates = rows.filter((a) => {
if (String(a.interface ?? "").trim() !== name) return false
if (String(a.disabled ?? "false").toLowerCase() === "true") return false
if (String(a.invalid ?? "false").toLowerCase() === "true") return false
const addr = String(a.address ?? "").trim()
return addr.includes("/")
})
candidates.sort((a, b) => {
const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0
return ad - bd
})
const raw = candidates[0]?.address
return raw ? String(raw).split("/")[0]?.trim() ?? "" : ""
}
function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): {
row: ResourceSampleRow | undefined
hasData: boolean
} {
if (rows.length === 0) return { row: undefined, hasData: false }
const last = rows[rows.length - 1]!
const lastTs = Date.parse(last.sampledAt)
const validOnline = (r: ResourceSampleRow) => r.status === "online" && r.totalMemory > 0
function parseRateToMbps(raw: unknown): number {
const txt = String(raw ?? "").trim().toLowerCase()
if (!txt) return 0
const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/)
if (m) {
const n = Number.parseFloat(m[1])
const u = m[2] ?? ""
if (!Number.isFinite(n)) return 0
if (u === "g") return n * 1000
if (u === "m") return n
if (u === "k") return n / 1000
return n / 1_000_000
if (validOnline(last)) {
return { row: last, hasData: true }
}
const n = Number.parseFloat(txt.replace(/[^\d.]/g, ""))
if (!Number.isFinite(n)) return 0
return n > 10000 ? n / 1_000_000 : n
}
const btestNodeLocks = new Map<number, Promise<void>>()
const fallback = [...rows].reverse().find(validOnline)
if (!fallback) return { row: undefined, hasData: false }
async function withBtestNodeLocks<T>(nodeIds: number[], fn: () => Promise<T>): Promise<T> {
const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b)
const releases: Array<() => void> = []
try {
for (const id of ids) {
const prev = btestNodeLocks.get(id)
let resolveCurrent!: () => void
const current = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
btestNodeLocks.set(id, current)
if (prev) await prev
releases.push(() => {
if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id)
resolveCurrent()
})
}
return await fn()
} finally {
for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]()
}
const fbTs = Date.parse(fallback.sampledAt)
if (lastTs - fbTs <= maxGapMs) return { row: fallback, hasData: true }
return { row: undefined, hasData: false }
}
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
@@ -104,19 +70,28 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const s = readUptimeSettings()
return reply.send({
enabled: s.enabled,
resourcesEnabled: s.resourcesEnabled ?? s.enabled,
pingEnabled: s.pingEnabled ?? s.enabled,
speedEnabled: s.speedEnabled ?? s.enabled,
/** Интервал сбора метрик ресурсов (сек). */
intervalSec: s.intervalSec,
probeIntervalSec: s.probeIntervalSec ?? s.intervalSec,
/** Интервал тика ping и значение по умолчанию для проб без своего interval. */
probeIntervalSec: s.probeIntervalSec,
speedIntervalSec: s.speedIntervalSec ?? 60,
retentionDays: s.retentionDays,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
scheduler: getSchedulerStatus(),
})
})
app.put("/uptime/settings", async (req, reply) => {
const body = req.body as {
enabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
intervalSec?: number | string
probeIntervalSec?: number | string
speedIntervalSec?: number | string
@@ -124,28 +99,37 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
}
const updated = updateUptimeSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
resourcesEnabled: body.resourcesEnabled,
pingEnabled: body.pingEnabled,
speedEnabled: body.speedEnabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 300),
probeIntervalSec: body.probeIntervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.probeIntervalSec), 10) || 15),
speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
refreshScheduler()
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
resourcesEnabled: updated.resourcesEnabled ?? updated.enabled,
pingEnabled: updated.pingEnabled ?? updated.enabled,
speedEnabled: updated.speedEnabled ?? updated.enabled,
intervalSec: updated.intervalSec,
probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec,
probeIntervalSec: updated.probeIntervalSec,
speedIntervalSec: updated.speedIntervalSec ?? 60,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
scheduler: getSchedulerStatus(),
},
})
})
app.post("/uptime/collect-now", async (_req, reply) => {
await collectUptimeOnce()
scheduleAlertEngineAfterDataCollectors()
const s = readUptimeSettings()
return reply.send({
ok: true,
@@ -155,8 +139,42 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
})
})
/** Ручной ping по списку id проб (группа «сервер + назначение» на UI); без ожидания interval_sec. */
app.post("/uptime/probes/collect-group", async (req, reply) => {
const body = req.body as { probeIds?: unknown }
const raw = body.probeIds
const probeIds = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : []
try {
const { polled } = await collectPingForProbeIds(probeIds)
if (polled > 0) scheduleAlertEngineAfterDataCollectors()
return reply.send({ ok: true, polled })
} catch (e) {
const status = (e as Error & { statusCode?: number }).statusCode
if (status === 409) {
return reply.status(409).send({ error: e instanceof Error ? e.message : "busy" })
}
throw e
}
})
app.get("/uptime/probes", async (_req, reply) => {
return reply.send({ probes: readProbeRows() })
const rows = readProbeRows()
return reply.send({
probes: rows.map((p) => ({
id: p.id,
srcServerId: String(p.srcServerId),
srcInterface: p.srcInterface || "",
name: p.name,
target: p.target,
probeFilter: p.probeFilter || "—",
enabled: p.enabled,
intervalSec: p.intervalSec ?? 0,
showOnDashboard: p.showOnDashboard,
sortOrder: p.sortOrder,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
})
})
app.get("/uptime/speed-probes", async (_req, reply) => {
@@ -275,6 +293,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
createdAt: now,
}).run()
}
refreshScheduler()
return reply.send({ ok: true })
})
@@ -289,6 +308,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
filter?: string
enabled?: boolean
showOnDashboard?: boolean
intervalSec?: number | string
}>
}
const normalized = (body.probes ?? [])
@@ -301,9 +321,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
showOnDashboard: p.showOnDashboard === true,
intervalSec: p.intervalSec == null || p.intervalSec === ""
? undefined
: Number.parseInt(String(p.intervalSec), 10),
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
refreshScheduler()
return reply.send({ ok: true })
})
@@ -366,186 +390,37 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
return await withBtestNodeLocks([srcId, dstId], async () => {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const srcAddressRows = srcInterface
? await srcClient.getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({
error: `На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`,
})
}
const initialAddress = dstAddress || dst.host
const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host
const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null
const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0)
const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0)
await new Promise<void>((resolve) => setTimeout(resolve, 3000))
const pingNow = new Date().toISOString()
let afterBtPing: {
rttMs: number | null
lossPct: number | null
error: string | null
} = { rttMs: null, lossPct: null, error: null }
try {
const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" })
const pr = parsePing(pingRows as Parameters<typeof parsePing>[0])
afterBtPing = {
rttMs: pr.avgRtt,
lossPct: pr.loss,
error: null,
}
} catch (pe) {
afterBtPing = {
rttMs: null,
lossPct: null,
error: pe instanceof Error ? pe.message : String(pe),
}
}
const probeId = String(body.probeId ?? "").trim()
const runId = String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: pingNow,
lastTxAvgMbps: txFinal,
lastRxAvgMbps: rxFinal,
lastStatus: "done",
lastError: "",
lastPingRttMs: afterBtPing.rttMs,
lastPingLossPct: afterBtPing.lossPct,
lastPingAt: pingNow,
lastPingError: afterBtPing.error ?? "",
updatedAt: pingNow,
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
db.insert(uptimeSpeedTestRuns).values({
id: runId,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: srcInterface || "",
dstInterface: dstInterface || "",
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
pingRttMs: afterBtPing.rttMs,
pingLossPct: afterBtPing.lossPct,
pingError: afterBtPing.error ?? null,
status: "done",
error: null,
createdAt: pingNow,
}).run()
return reply.send({
ok: true,
result: {
runId,
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
address: initialAddress,
srcAddress: srcAddress || src.host,
dstAddress: initialAddress,
srcInterfaceAddress,
dstInterfaceAddress,
protocol,
direction,
durationSec,
txAvgMbps: txFinal,
rxAvgMbps: rxFinal,
txSeries,
rxSeries,
raw: rows,
afterBtPing,
},
})
const result = await runBandwidthSpeedTest({
runId: String(body.runId ?? "").trim() || undefined,
probeId: String(body.probeId ?? "").trim() || undefined,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: String(body.srcInterface ?? "").trim() || undefined,
dstInterface: String(body.dstInterface ?? "").trim() || undefined,
protocol: body.protocol === "udp" ? "udp" : "tcp",
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
})
return reply.send({ ok: true, result })
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
recordSpeedTestFailure({
probeId: String(body.probeId ?? "").trim() || undefined,
runId: String(body.runId ?? "").trim() || undefined,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: String(body.srcInterface ?? "").trim(),
dstInterface: String(body.dstInterface ?? "").trim(),
protocol: body.protocol === "udp" ? "udp" : "tcp",
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
message: raw,
})
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim()
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
updatedAt: new Date().toISOString(),
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
const body = req.body as {
runId?: string
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
}
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
if (Number.isFinite(srcId) && Number.isFinite(dstId)) {
db.insert(uptimeSpeedTestRuns).values({
id: String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
probeId: probeId || null,
srcServerId: srcId,
dstServerId: dstId,
srcInterface: String(body.srcInterface ?? "").trim(),
dstInterface: String(body.dstInterface ?? "").trim(),
srcAddress: null,
dstAddress: null,
srcInterfaceAddress: null,
dstInterfaceAddress: null,
protocol: body.protocol === "udp" ? "udp" : "tcp",
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
txAvgMbps: null,
rxAvgMbps: null,
pingRttMs: null,
pingLossPct: null,
pingError: null,
status: "error",
error: message,
createdAt: new Date().toISOString(),
}).run()
if (/активного IP|Server not found/i.test(raw)) {
return reply.status(400).send({ error: message })
}
return reply.status(502).send({ error: message })
}
@@ -601,6 +476,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const allServers = db.select().from(servers).all()
const probeRows = readProbeRows()
const probeSamples = readProbeSamplesSince(sinceIso)
const uptimeCfg = readUptimeSettings()
const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300)
/** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */
const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000))
const probes = probeRows.map((p) => {
const rows = probeSamples.filter((r) => r.probeId === p.id)
@@ -624,18 +503,19 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
const resources = allServers.map((s) => {
const rows = readResourceSamplesSince(sinceIso, s.id)
const last = rows[rows.length - 1]
const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs)
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
return {
serverId: String(s.id),
cpu: last?.cpuLoad ?? 0,
hasData,
cpu: pick?.cpuLoad ?? 0,
cpuHistory,
ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)),
ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)),
hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)),
hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)),
uptimeSeconds: last?.uptimeSeconds ?? 0,
boardName: last?.boardName || "RouterBOARD",
ramUsed: Math.max(0, ((pick?.totalMemory ?? 0) - (pick?.freeMemory ?? 0)) / (1024 * 1024)),
ramTotal: Math.max(0, (pick?.totalMemory ?? 0) / (1024 * 1024)),
hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)),
hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)),
uptimeSeconds: pick?.uptimeSeconds ?? 0,
boardName: pick?.boardName || "RouterBOARD",
temp: undefined as number | undefined,
}
})