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:
1 parent
bdb9b72fac
commit
5f31bb47fb
81 files changed
+11976
-1239
No files matched your search
@@ -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
@@ -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([])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user