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:
Denozordec
2026-05-07 12:29:04 +07:00
co-authored by Cursor
parent bdb9b72fac
commit 5f31bb47fb
81 changed files with 11976 additions and 1239 deletions
+19
View File
@@ -0,0 +1,19 @@
---
description: "Быстрый запуск локального frontend и backend"
alwaysApply: true
---
# Локальный запуск проекта
Для запуска всего проекта в dev-режиме поднимать два процесса:
```powershell
npm run dev
npm --prefix backend run dev
```
- Frontend: `http://localhost:3000`
- Backend: `http://localhost:8000`
- Health check backend: `http://localhost:8000/health`
Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами.
+47 -7
View File
@@ -1,10 +1,50 @@
node_modules
.next
dist
build
coverage
*.tsbuildinfo
# dependencies
node_modules/
# next.js
.next/
out/
# production
build/
dist/
# turbo
.turbo/
# logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# cache
.cache/
.parcel-cache/
# coverage
coverage/
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# misc
.vercel/
tmp/
temp/
+2547 -149
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -57,8 +57,8 @@ export default function AsnsPage() {
</div>
<DataTable
data={rows}
searchPlaceholder="Поиск по ASN, организации…"
searchKeys={["asn", "org", "country"]}
searchPlaceholder="Поиск по ASN, имени, префиксам…"
searchKeys={["asn", "org", "prefixes"]}
columns={[
{
key: "asn",
@@ -67,13 +67,12 @@ export default function AsnsPage() {
},
{
key: "org",
label: "Организация",
render: (d) => <span className="font-medium">{d.org}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
label: "Имя / организация",
render: (d) => (
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
{d.org}
</span>
),
},
{
key: "prefixes",
+6 -6
View File
@@ -279,13 +279,13 @@ export default function CommunitiesPage() {
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
{c.prefixCount.toLocaleString("ru-RU")}
</td>
<td className="px-4 py-2.5 text-right">
<td className="px-4 py-2.5 text-right tabular-nums">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount}</span>
<span className="font-mono text-xs">{c.serverCount.toLocaleString("ru-RU")}</span>
</div>
</td>
<td className="px-4 py-2.5">
@@ -337,8 +337,8 @@ export default function CommunitiesPage() {
{[
["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
["Серверов", String(selected.serverCount)],
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2">
<span className="text-xs text-muted-foreground">{k}</span>
File diff suppressed because it is too large Load Diff
+159 -20
View File
@@ -27,12 +27,17 @@ import {
import {
collectGreEndpointHostnames,
greResolvedMapFromApi,
isIpv4String,
normalizeGreEndpointAddr,
} from "@/lib/gre-endpoint-resolve"
import {
assignSpeedProbesToGreTunnels,
assignSpeedProbesToWanJhEdges,
canonicalGreServerPairKey,
greOuterSummaryLine,
ifaceNameForGreOuterIp,
mergeGreMetricsWithSpeedProbe,
wanJhEdgeMapKey,
type GreSpeedProbeSnapshot,
} from "@/lib/map-gre-speed-probe"
import { Button } from "@/components/ui/button"
@@ -318,6 +323,12 @@ function edgeBadgePosition(
type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"
function greEdgeKey(e: GreMapEdge): string {
const la = (e.tunnel.localAddress ?? "").trim()
const ra = (e.tunnel.remoteAddress ?? "").trim()
return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}`
}
// ─── SVG sub-components ───────────────────────────────────────────────────────
/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
@@ -697,7 +708,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
<rect width={W} height={H} fill="#060d1a" />
{/* simplified edges */}
{greEdges.map((e) => (
<line key={e.tunnel.id} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
<line key={greEdgeKey(e)} x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
))}
{/* WAN edges */}
@@ -831,7 +842,18 @@ export default function NetworkMapPage() {
lastPingRttMs: p.lastPingRttMs,
})),
)
const hostnames = collectGreEndpointHostnames(mappedTunnels)
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
for (const row of rows) {
if (!Array.isArray(row.wanUplinks)) continue
for (const w of row.wanUplinks) {
const raw = (w.ip ?? "").trim()
if (!raw) continue
const n = normalizeGreEndpointAddr(raw)
if (!n || n === "0.0.0.0") continue
if (!isIpv4String(n)) hostSet.add(n.toLowerCase())
}
}
const hostnames = [...hostSet]
if (hostnames.length > 0) {
try {
const res = await apiFetch<{ results: Record<string, string | null> }>(
@@ -900,6 +922,10 @@ export default function NetworkMapPage() {
try {
const k = "mm-network-map-layout-rev"
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
// Важно: без явного сброса старые drag-координаты остаются в state
// и перекрывают новый авто-лейаут на live-данных.
setNodePositions({})
setSatPositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
}
} catch {
@@ -957,12 +983,21 @@ export default function NetworkMapPage() {
[mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap],
)
/** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */
/** Speed-пробы на сегментах WANJH (тот же формат бейджа, что на GRE JH↔EN). */
const speedProbeByWanJhKey = useMemo(
() => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes),
[wanJhEdges, mapServers, speedProbes],
)
/**
* WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу
* (JH или exit — иначе «второй» бейдж speed посередине сегмента).
*/
const suppressWanJhPingBadge = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type !== "jump-host") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
@@ -986,7 +1021,7 @@ export default function NetworkMapPage() {
() =>
assignSpeedProbesToGreTunnels(
greEdges.map((e) => ({
tunnel: e.tunnel,
tunnel: { ...e.tunnel, id: greEdgeKey(e) },
fromServer: e.fromServer,
toServer: e.toServer,
})),
@@ -996,14 +1031,77 @@ export default function NetworkMapPage() {
[greEdges, speedProbes, greResolvedMap],
)
/** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
const spGre = speedProbeByTunnelId.get(greEdgeKey(g))
const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel))
if (!merged.hasSpeedMonitor) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
}
return s
}, [greEdges, speedProbeByTunnelId, greResolvedMap])
/** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */
const suppressWanJhLineDuplicate = useMemo(() => {
const s = new Set<string>()
for (const g of greEdges) {
if (g.fromServer.type !== "home-router") continue
if (g.toServer.type === "home-router") continue
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
if (widx == null) continue
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
}
return s
}, [greEdges, greResolvedMap])
const visibleWanJhEdges = useMemo(
() => wanJhEdges.filter((e) => !suppressWanJhLineDuplicate.has(wanJhEdgeMapKey(e))),
[wanJhEdges, suppressWanJhLineDuplicate],
)
/** Один бейдж на совпадающий отрезок карты (два туннеля в БД HR→MSK и MSK→HR — одна линия). */
const greTunnelIdsWithMapBadge = useMemo(() => {
const edgeByTunnel = new Map(greEdges.map((e) => [greEdgeKey(e), e] as const))
const geomKey = (e: GreMapEdge) => {
const a = `${Math.round(e.from.x)},${Math.round(e.from.y)}`
const b = `${Math.round(e.to.x)},${Math.round(e.to.y)}`
return a <= b ? `${a}|${b}` : `${b}|${a}`
}
const rank = (tid: string): number => {
const e = edgeByTunnel.get(tid)
if (!e) return -1
const sp = speedProbeByTunnelId.get(tid)
const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel))
let r = m.hasSpeedMonitor ? 1000 : 0
r += m.pingMs ?? 0
return r
}
const winners = new Map<string, string>()
for (const e of greEdges) {
const key = geomKey(e)
const cur = winners.get(key)
const edgeId = greEdgeKey(e)
if (!cur || rank(edgeId) > rank(cur) || (rank(edgeId) === rank(cur) && edgeId.localeCompare(cur) < 0)) {
winners.set(key, edgeId)
}
}
return new Set(winners.values())
}, [greEdges, speedProbeByTunnelId])
/** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
const greBadgeStaggerByTunnelId = useMemo(() => {
const pairOrder = new Map<string, number>()
const out = new Map<string, number>()
for (const ge of greEdges) {
const k = `${ge.fromServer.id}:${ge.toServer.id}`
const k = canonicalGreServerPairKey(ge.fromServer, ge.toServer)
const lane = pairOrder.get(k) ?? 0
out.set(ge.tunnel.id, lane)
out.set(greEdgeKey(ge), lane)
pairOrder.set(k, lane + 1)
}
return out
@@ -1216,6 +1314,16 @@ export default function NetworkMapPage() {
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
}
function openWanJhSpeedDetail(ev: React.MouseEvent<SVGElement>, edge: WanJhEdge) {
ev.stopPropagation()
const home = mapServers.find((s) => s.id === edge.homeId)
if (!home) return
setSelectedGreEdge(null)
setSelected(home)
setSelWanIdx(edge.wanIdx)
setHoveredId(null)
}
const connectedTunnels = selected
? mapGreTunnels.filter((t) => {
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
@@ -1410,6 +1518,7 @@ export default function NetworkMapPage() {
{/* ── GRE edges ── */}
{greEdges.map((e, i) => {
const edgeId = greEdgeKey(e)
const ts = TUNNEL_STYLE[e.tunnel.status]
const fromN = nodeById[e.fromServer.id]
const toN = nodeById[e.toServer.id]
@@ -1419,12 +1528,12 @@ export default function NetworkMapPage() {
!isVisible(fromN) &&
!isVisible(toN)
const baseProbe = greTunnelProbe(e.tunnel)
const spGre = speedProbeByTunnelId.get(e.tunnel.id)
const spGre = speedProbeByTunnelId.get(edgeId)
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const rttFromMonitor = spGre?.lastPingRttMs != null
const throughputFromMonitor =
spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
const badgeLane = greBadgeStaggerByTunnelId.get(e.tunnel.id) ?? 0
const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0
const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052))
const normalPx =
(badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
@@ -1443,7 +1552,7 @@ export default function NetworkMapPage() {
setSelWanIdx(null)
}
return (
<g key={e.tunnel.id} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<g key={edgeId} opacity={dimmed ? 0.05 : 1} style={{ transition: "opacity 0.3s" }}>
<line
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
stroke={ts.stroke} strokeWidth="1.5"
@@ -1467,7 +1576,7 @@ export default function NetworkMapPage() {
path={`M ${e.from.x} ${e.from.y} L ${e.to.x} ${e.to.y}`} />
</circle>
)}
{showPingBadges && (
{showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && (
<GreEdgeMetricBadge
mx={mx}
my={my}
@@ -1503,7 +1612,7 @@ export default function NetworkMapPage() {
})}
{/* ── WAN→JH edges ── */}
{wanJhEdges.map((edge, i) => {
{visibleWanJhEdges.map((edge, i) => {
const jh = nodeById[edge.jhId]
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
if (!jh || !satPos) return null
@@ -1528,10 +1637,35 @@ export default function NetworkMapPage() {
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
</circle>
)}
{showPingBadges &&
!suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && (
<PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
)}
{showPingBadges && (() => {
const kj = wanJhEdgeMapKey(edge)
const sp = speedProbeByWanJhKey.get(kj)
const merged = mergeGreMetricsWithSpeedProbe(sp, {
pingMs: edge.pingMs,
dlMbps: null,
ulMbps: null,
})
const hasMonitor = merged.hasSpeedMonitor
if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null
if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null
if (hasMonitor) {
return (
<GreEdgeMetricBadge
mx={mx}
my={my}
pingMs={merged.pingMs}
dl={merged.dlMbps}
ul={merged.ulMbps}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
rttFromMonitor={sp?.lastPingRttMs != null}
throughputFromMonitor={
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
}
/>
)
}
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
})()}
</g>
)
})}
@@ -1677,7 +1811,7 @@ export default function NetworkMapPage() {
nodes={nodes}
greEdges={greEdges}
satPos={effectiveSatPos}
wanJhEdges={wanJhEdges}
wanJhEdges={visibleWanJhEdges}
homeRouters={homeRouters}
onClose={() => setShowMinimap(false)}
onPan={(x, y) => setPan({ x, y })}
@@ -1743,10 +1877,11 @@ export default function NetworkMapPage() {
{(() => {
const edge = selectedGreEdge
const t = edge.tunnel
const selectedEdgeId = greEdgeKey(edge)
const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
const baseProbe = greTunnelProbe(t)
const spGr = speedProbeByTunnelId.get(t.id)
const spGr = speedProbeByTunnelId.get(selectedEdgeId)
const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe)
const rttMon = spGr?.lastPingRttMs != null
const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null
@@ -2039,16 +2174,20 @@ export default function NetworkMapPage() {
: mapServers.find((s) => s.id === t.serverId)
const fromServer = mapServers.find((s) => s.id === t.serverId)
const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
const tunnelPanelKey =
fromServer && toServer
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
const baseProbe = greTunnelProbe(t)
const spGre =
fromServer && toServer ? speedProbeByTunnelId.get(t.id) : undefined
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
const pc = pingColor(merged.pingMs)
const showMetrics =
merged.pingMs != null ||
(merged.dlMbps != null && merged.ulMbps != null)
return (
<div key={t.id} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono font-medium">{t.name}</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
+118 -9
View File
@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -119,6 +120,28 @@ interface BackendOspfAll {
bfdSessions: BackendBfdSession[]
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
interface BackendOspfOptimizeResponse {
serverId: number
serverName: string
pingWeight: number
optimizedCount: number
applied: Array<{ interface: string; from: number; to: number }>
interfaces: Array<{
id: string
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
@@ -625,11 +648,25 @@ function NodeDetailPanel({
// ─── interfaces tab ───────────────────────────────────────────────────────────
function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isLive: boolean }) {
function InterfacesTab({
items: initialItems,
isLive,
filterServerId,
backendUrl,
onLiveDataRefresh,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
}) {
const [items, setItems] = useState<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
const [dragOver, setDragOver] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
// Sync with live data when it changes
useEffect(() => {
@@ -660,17 +697,67 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
if (isLive) return {}
const out: Record<string, { optimalCost: number; prob: number }> = {}
grouped.forEach(router => {
router.areas.forEach(ag => {
const ranked = [...ag.items]
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
.sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName))
ranked.forEach(({ item, prob }, idx) => { out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } })
const ranked = router.areas
.flatMap(ag => ag.items)
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
.sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName))
// В рамках одного роутера выдаём строго уникальные optimal cost.
ranked.forEach(({ item, prob }, idx) => {
out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob }
})
})
return out
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== "all"
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
items.forEach((item) => {
if (!byRouter[item.routerKey]) byRouter[item.routerKey] = []
byRouter[item.routerKey].push(item)
})
Object.values(byRouter).forEach((routerItems) => {
routerItems
.slice()
.sort((a, b) => a.cost - b.cost || a.interfaceName.localeCompare(b.interfaceName))
.forEach((item, idx) => {
out[item.key] = (idx + 1) * COST_STEP
})
})
return out
}, [items])
async function handleLiveOptimize() {
if (!canOptimizeLive) {
showToast("Выберите конкретный сервер для оптимизации OSPF")
return
}
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
})
setLiveOptimalCost(byKey)
showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setOptimizing(false)
}
}
function onDrop(routerKey: string, area: string, targetKey: string) {
if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return }
@@ -704,6 +791,12 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
<WandSparklesIcon className="size-4" />Оптимизировать
</Button>
)}
{canOptimizeLive && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
{!isLive && (
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
<SaveIcon className="size-4" />Сохранить
@@ -750,6 +843,8 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
{ag.items.map(item => {
const hint = hints[item.key]
const matches = hint && hint.optimalCost === item.cost
const liveOptimal = liveOptimalCost[item.key] ?? uniqueLiveFallbackOpt[item.key] ?? item.cost
const costDiffers = liveOptimal !== item.cost
return (
<div key={item.key}
draggable={!isLive}
@@ -779,7 +874,11 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
}>opt {hint.optimalCost}</Chip>
)}
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cost {item.cost}</Chip>
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cur {item.cost}</Chip>
<Chip color={costDiffers
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}>opt {liveOptimal}</Chip>
</div>
)
})}
@@ -1184,7 +1283,9 @@ export default function OspfPage() {
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
}
const items = liveData.interfaces.map(backendToItem)
const items = liveData.interfaces
.map(backendToItem)
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
@@ -1389,7 +1490,15 @@ export default function OspfPage() {
))}
</div>
{activeTab === "interfaces" && <InterfacesTab items={displayItems} isLive={isLive} />}
{activeTab === "interfaces" && (
<InterfacesTab
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
/>
)}
{activeTab === "neighbors" && (
<NeighborsTab
neighbors={displayNeighbors}
+12 -4
View File
@@ -529,15 +529,20 @@ export default function RecursiveRoutesPage() {
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
const [liveServerListReady, setLiveServerListReady] = useState(false)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(true)
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
// eslint-disable-next-line react-hooks/set-state-in-effect
setLiveServerListReady(false)
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
@@ -548,10 +553,13 @@ export default function RecursiveRoutesPage() {
setServers([])
setSelectedServerId("")
})
.finally(() => {
setLiveServerListReady(true)
})
}, [isLive, apiFetch])
const loadRoutes = useCallback(async () => {
if (!isLive || !selectedServerId) return
if (!isLive || !liveServerListReady || !selectedServerId) return
setOpError(null)
setBusy("load")
try {
@@ -563,17 +571,17 @@ export default function RecursiveRoutesPage() {
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !selectedServerId) return
if (!isLive || !liveServerListReady || !selectedServerId) return
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
}
}, [isLive, selectedServerId, apiFetch])
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
+273 -12
View File
@@ -22,11 +22,13 @@ import {
type OptimizerData,
type OptimizerSettings,
type OptimizerApiServer,
type RouteOptimizerSpeedProbe,
buildLiveOptimizerData,
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
mapApiServersToTopology,
readStoredRouteOptimizerSettings,
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
calcRouteScore,
} from "@/lib/route-optimizer-data"
import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
@@ -93,13 +95,6 @@ function jitter(base: number, range: number) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * range * 2))
}
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
const pingScore = Math.max(0, 100 - pingMs * 0.6)
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
const w = pw / 100
return Math.round(w * pingScore + (1 - w) * speedScore)
}
function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" {
return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW"
}
@@ -161,7 +156,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
wanJhLegs.push({
wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul,
loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0,
score: calcScore(ping, dl, ul, pw),
score: calcRouteScore(ping, dl, ul, pw),
})
}
}
@@ -177,7 +172,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
const totalPing = hw.pingMs + je.pingMs
const dl = Math.min(hw.dlMbps, je.dlMbps)
const ul = Math.min(hw.ulMbps, je.ulMbps)
const score = calcScore(totalPing, dl, ul, pw)
const score = calcRouteScore(totalPing, dl, ul, pw)
fullRoutes.push({
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
homeId: home.id, wan, jh, exit: ex, hw, je,
@@ -521,7 +516,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
</tr>
</thead>
<tbody className="divide-y divide-border">
{recs.map(r => {
{recs.map((r, idx) => {
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
const isApplied = applied.has(pinKey)
@@ -529,7 +524,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
const canApply = r.shouldSwitch && !isPinned && !isApplied
return (
<tr key={r.community} className={cn(
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
"hover:bg-muted/30 transition-colors",
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
isApplied && "bg-emerald-500/5",
@@ -782,6 +777,7 @@ export default function RouteOptimizerPage() {
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
const [liveServers, setLiveServers] = useState<OptimizerApiServer[]>([])
const [showSettings, setShowSettings] = useState(false)
const [pinned, setPinned] = useState<Set<string>>(new Set())
@@ -792,6 +788,34 @@ export default function RouteOptimizerPage() {
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
const [selectedVrf, setSelectedVrf] = useState("main")
const [ospfServerId, setOspfServerId] = useState("")
const [ospfApplying, setOspfApplying] = useState(false)
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
const [ospfApplyError, setOspfApplyError] = useState("")
const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null)
const [ospfPreviewError, setOspfPreviewError] = useState("")
const [ospfPreview, setOspfPreview] = useState<{
changedCount: number
interfacesTotal: number
interfaces: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
changes: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
} | null>(null)
const load = useCallback(async (override?: OptimizerSettings) => {
const s = override ?? settingsRef.current
@@ -803,9 +827,13 @@ export default function RouteOptimizerPage() {
setData(buildMockData(s))
setLiveJumpHosts([])
setLiveExitNodes([])
setLiveServers([])
return
}
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
const enabledRows = rows.filter((r) => r.enabled)
setLiveServers(enabledRows)
setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? "")))
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
setLiveJumpHosts(jumpHosts)
setLiveExitNodes(exitNodes)
@@ -819,13 +847,21 @@ export default function RouteOptimizerPage() {
} catch {
rulesets = null
}
let speedProbes: RouteOptimizerSpeedProbe[] = []
try {
const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes")
speedProbes = sp.probes ?? []
} catch {
speedProbes = []
}
setData(buildLiveOptimizerData(rows, rulesets, s))
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
} catch (e) {
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
setData(null)
setLiveJumpHosts([])
setLiveExitNodes([])
setLiveServers([])
} finally {
setLoading(false)
}
@@ -929,9 +965,104 @@ export default function RouteOptimizerPage() {
)
}
async function applyOspfOptimization() {
if (!ospfServerId) return
setOspfApplying(true)
setOspfApplyError("")
setOspfApplyResult(null)
try {
const res = await apiFetch<{ serverName: string; optimizedCount: number }>(
`/api/servers/${ospfServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: settings.pingWeight }),
},
)
setOspfApplyResult({
serverName: res.serverName,
optimizedCount: res.optimizedCount,
})
await load()
setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev))
} catch (e) {
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
} finally {
setOspfApplying(false)
}
}
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
useEffect(() => {
if (!useLiveData || !ospfServerId) return
let cancelled = false
void apiFetch<{
interfaces: Array<{ areaId: string; interface: string }>
instances: Array<unknown>
neighbors: Array<unknown>
bfdSessions: Array<unknown>
}>(`/api/servers/${ospfServerId}/ospf`)
.then((data) => {
if (cancelled) return
const selected = liveServers.find((s) => String(s.id) === ospfServerId)
const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim()))
setOspfMeta({
interfaces: visibleInterfaces.length,
areas: new Set(visibleInterfaces.map((i) => i.areaId)).size,
serverName: selected?.name || selected?.host || ospfServerId,
})
})
.catch(() => {
if (cancelled) return
setOspfMeta(null)
})
return () => { cancelled = true }
}, [apiFetch, liveServers, ospfServerId, useLiveData])
useEffect(() => {
if (!useLiveData || !ospfServerId) return
let cancelled = false
void apiFetch<{
changedCount: number
interfacesTotal: number
interfaces: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
changes: Array<{
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}>(
`/api/servers/${ospfServerId}/ospf/optimize/preview`,
{ method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) },
)
.then((data) => {
if (cancelled) return
setOspfPreviewError("")
setOspfPreview(data)
})
.catch((e) => {
if (cancelled) return
setOspfPreview(null)
setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF")
})
return () => { cancelled = true }
}, [apiFetch, ospfServerId, settings.pingWeight, useLiveData])
const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError
return (
<div className="flex flex-col h-full">
<PageHeader
@@ -1081,6 +1212,136 @@ export default function RouteOptimizerPage() {
)}
</Card>
{/* OSPF optimization from Route Optimizer */}
<Card>
<CardContent className="px-5 py-4 flex flex-col gap-2.5">
<div className="flex items-center gap-2 text-sm">
<NetworkIcon className="size-4 text-sky-400 shrink-0" />
<span className="font-semibold">OSPF</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
Route AI weight: ping {settings.pingWeight}%
</span>
</div>
{!useLiveData && (
<p className="text-xs text-muted-foreground">
Доступно только в режиме живых данных.
</p>
)}
{useLiveData && (
<>
<div className="rounded-lg border bg-muted/15 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/80 flex-wrap">
<select
className="text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 min-w-64 focus:outline-none focus:ring-1 focus:ring-ring"
value={ospfServerId}
onChange={(e) => setOspfServerId(e.target.value)}
>
{liveServers.length === 0 && <option value="">Нет доступных серверов</option>}
{liveServers.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name || s.host} ({s.host})
</option>
))}
</select>
<span className="text-[11px] text-muted-foreground">
{ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
</span>
<Button
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={!ospfServerId || ospfApplying}
>
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
</Button>
</div>
<div className="px-3 py-2 text-xs grid grid-cols-1 md:grid-cols-4 gap-2">
<div className="text-muted-foreground">Router</div>
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
<div className="text-right text-muted-foreground">
apply: {ospfApplyResult?.optimizedCount ?? 0}
</div>
</div>
</div>
<div className="rounded-lg border bg-background/70 overflow-hidden">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
<span>Preview изменений OSPF cost (до применения)</span>
<span>
{ospfPreviewLoading
? "расчёт…"
: ospfPreview
? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся`
: "нет данных"}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/30">
{["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => (
<th key={h} className="text-left px-3 py-1.5 font-medium text-muted-foreground">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{ospfPreviewError && (
<tr>
<td colSpan={5} className="px-3 py-2 text-destructive">
Ошибка preview: {ospfPreviewError}
</td>
</tr>
)}
{!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && (
<tr>
<td colSpan={5} className="px-3 py-2 text-muted-foreground">
Интерфейсы OSPF не найдены для выбранного сервера.
</td>
</tr>
)}
{(ospfPreview?.interfaces ?? []).map((row) => (
<tr key={`${row.interface}-${row.currentCost}-${row.optimalCost}`}>
<td className="px-3 py-1.5 font-mono">{row.interface}</td>
<td className="px-3 py-1.5 font-mono">
<span className="text-sky-600 dark:text-sky-400">{row.currentCost}</span>
{" → "}
<span className={row.currentCost === row.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"}
>
{row.optimalCost}
</span>
</td>
<td className="px-3 py-1.5 font-mono">{row.score}</td>
<td className="px-3 py-1.5 font-mono">{row.pingMs}ms</td>
<td className="px-3 py-1.5 font-mono">{row.dlMbps} / {row.ulMbps}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{ospfApplyResult && (
<div className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
Применено на {ospfApplyResult.serverName}: изменено интерфейсов {ospfApplyResult.optimizedCount}.
</div>
)}
{ospfApplyError && (
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
Ошибка OSPF-оптимизации: {ospfApplyError}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
+2 -189
View File
@@ -61,7 +61,7 @@ const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[
{ group: "Данные", icon: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Настройки"] },
{ group: "Система", icon: <ServerIcon className="size-3" />, items: ["Оповещения", "Сбор данных", "Настройки"] },
]
const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items)
@@ -147,21 +147,9 @@ const PERM_COLOR: Record<PermLevel, string> = {
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
}
const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
type NavSection = typeof SECTIONS_NAV[number]
interface CollectorSettingsDto {
enabled: boolean
intervalSec: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays: number
lastCollectedAt: string | null
lastDurationMs: number | null
lastError: string | null
collectorRunning?: boolean
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
@@ -864,17 +852,6 @@ export default function SettingsPage() {
const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
const [showEvoKey, setShowEvoKey] = useState(false)
// collectors
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null)
const [collectorError, setCollectorError] = useState<string | null>(null)
// general
const [lang, setLang] = useState("ru")
const [theme, setTheme] = useState("system")
@@ -927,31 +904,6 @@ export default function SettingsPage() {
// total sub-users count for summary
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
const loadCollectors = useCallback(async () => {
if (mode !== "live" || backendStatus !== true) return
setCollectorError(null)
try {
const [traffic, uptime] = await Promise.all([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/uptime/settings"),
])
setTrafficCollector(traffic)
setUptimeCollector(uptime)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков")
}
}, [apiFetch, backendStatus, mode])
useEffect(() => {
if (section !== "Сбор данных") return
queueMicrotask(() => { void loadCollectors() })
}, [section, loadCollectors])
useEffect(() => {
if (section !== "EvoBGP") return
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
@@ -1178,145 +1130,6 @@ export default function SettingsPage() {
</div>
)
// ── Сбор данных ──
if (section === "Сбор данных") return (
<div className="space-y-4">
{(mode !== "live" || backendStatus !== true) && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-sm font-medium">Раздел доступен только в live-режиме</p>
<p className="text-xs text-muted-foreground mt-1">
Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`.
</p>
</CardContent>
</Card>
)}
{mode === "live" && backendStatus === true && (
<>
{collectorError && (
<Card>
<CardContent className="pt-4 pb-4 px-4">
<p className="text-xs text-destructive">{collectorError}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор трафика</CardTitle>
<CardDescription className="text-xs">Настройки для `/traffic`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", trafficCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !trafficCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "traffic"}
onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={trafficIntervalDraft} onChange={(e) => setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" />
<Input value={trafficRetentionDraft} onChange={(e) => setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try {
await apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ intervalSec: Number.parseInt(trafficIntervalDraft, 10) || 30, retentionDays: Number.parseInt(trafficRetentionDraft, 10) || 14 }) })
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "traffic"} onClick={async () => {
setCollectorBusy("traffic")
try { await apiFetch("/api/traffic/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Сбор uptime</CardTitle>
<CardDescription className="text-xs">Настройки для `/uptime`</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5">
<div className="flex items-center gap-3">
<span className="text-sm">Состояние</span>
<div className="flex rounded-md border border-input overflow-hidden h-8">
<button className={cn("px-3 text-xs", uptimeCollector?.enabled ? "bg-emerald-600 text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: true }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Вкл</button>
<button className={cn("px-3 text-xs border-l border-input", !uptimeCollector?.enabled ? "bg-muted-foreground text-white" : "text-muted-foreground hover:bg-muted")}
disabled={collectorBusy === "uptime"}
onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ enabled: false }) }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Выкл</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input value={uptimeIntervalDraft} onChange={(e) => setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" />
<Input value={uptimeSpeedIntervalDraft} onChange={(e) => setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" />
</div>
<div className="grid grid-cols-1 gap-3">
<Input value={uptimeRetentionDraft} onChange={(e) => setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try {
await apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
probeIntervalSec: Number.parseInt(uptimeIntervalDraft, 10) || 15,
speedIntervalSec: Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60,
retentionDays: Number.parseInt(uptimeRetentionDraft, 10) || 14,
}),
})
await loadCollectors()
} finally { setCollectorBusy(null) }
}}>Сохранить</Button>
<Button size="sm" variant="outline" disabled={collectorBusy === "uptime"} onClick={async () => {
setCollectorBusy("uptime")
try { await apiFetch("/api/uptime/collect-now", { method: "POST" }); await loadCollectors() }
finally { setCollectorBusy(null) }
}}>Собрать сейчас</Button>
</div>
<div className="text-xs text-muted-foreground">
<p>Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}</p>
<p>Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}</p>
</div>
</CardContent>
</Card>
</>
)}
</div>
)
// ── EvoBGP ──
if (section === "EvoBGP") return (
<div className="space-y-4">
+14 -5
View File
@@ -290,8 +290,17 @@ const userTraffic: UserTraffic[] = [
// ─── types ────────────────────────────────────────────────────────────────────
const RANGES = ["5м", "15м", "1ч", "4ч", "24ч"] as const
type Range = typeof RANGES[number]
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
type Range = (typeof TRAFFIC_RANGE_KEYS)[number]
const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
"5m": "5м",
"15m": "15м",
"1h": "1ч",
"4h": "4ч",
"24h": "24ч",
}
type GroupMode = "servers" | "users" | "gre"
type SortField = "rx" | "tx" | "name" | "sessions"
@@ -575,10 +584,10 @@ function DetailHeader({ range, setRange, children }: {
<div className="flex items-start justify-between mb-3 gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
<div className="flex gap-1 shrink-0">
{RANGES.map(r => (
{TRAFFIC_RANGE_KEYS.map((r) => (
<Button key={r} size="sm" variant={r === range ? "default" : "ghost"}
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
{r}
{TRAFFIC_RANGE_LABELS[r]}
</Button>
))}
</div>
@@ -772,7 +781,7 @@ export default function TrafficPage() {
const [sortField, setSortField] = useState<SortField>("rx")
const [sortDir, setSortDir] = useState<SortDir>("desc")
const [selectedId, setSelectedId] = useState("srv1")
const [range, setRange] = useState<Range>("1ч")
const [range, setRange] = useState<Range>("1h")
const [search, setSearch] = useState("")
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
const [liveBusy, setLiveBusy] = useState(false)
+461 -166
View File
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"
import { Flag } from "@/components/flag"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
import { cn } from "@/lib/utils"
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
import type { PingProbe } from "@/lib/data"
@@ -29,6 +30,11 @@ import {
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
} from "@/components/ui/sheet"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
@@ -85,7 +91,7 @@ function jitter(base: number, pct: number) {
function rttColor(rtt: number | null, loss: number): string {
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
if (loss > 1 || rtt > 60) return "text-[var(--status-degraded-fg)]"
if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]"
return "text-[var(--status-online-fg)]"
}
@@ -93,6 +99,131 @@ function probeSparkColor(status: PingProbe["status"]): string {
return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)"
}
/** Развёрнутый график RTT под мини-спарклайном (та же серия `PingProbe.series`). */
function ProbePingRttDetailChart({
series,
status,
probeName,
target,
}: {
series: number[]
status: PingProbe["status"]
probeName: string
target: string
}) {
const stroke = probeSparkColor(status)
const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0))
const valid = data.filter((v) => Number.isFinite(v))
if (valid.length === 0) {
return (
<p className="text-xs text-muted-foreground">
Нет числовых точек RTT для графика (проба «{probeName}» {target}).
</p>
)
}
const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0]
const W = 720
const H = 168
const pad = { l: 48, r: 14, t: 14, b: 36 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...chartPts, 1)
const minVal = Math.min(...chartPts)
const span = Math.max(1, maxVal - minVal) * 1.08
const y0 = minVal - (span - (maxVal - minVal)) / 2
const y1 = y0 + span
const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw)
const yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih
const lineD = chartPts
.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`)
.join(" ")
const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z`
const gridVals = [0, 0.25, 0.5, 0.75, 1]
const fmt = (v: number) => `${Math.round(v)} мс`
return (
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{probeName}</span>
<span className="font-mono ml-1.5">{target}</span>
</p>
<p className="text-[11px] text-muted-foreground">Ось X: старые замеры слева новые справа · обзор ~1 ч</p>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full max-w-[720px] h-[min(200px,42vw)] min-h-[140px]"
style={{ display: "block" }}
preserveAspectRatio="xMidYMid meet"
>
{gridVals.map((g, i) => {
const y = pad.t + ih * (1 - g)
return (
<g key={i}>
<line
x1={pad.l}
x2={W - pad.r}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeDasharray={g === 0 ? "0" : "2 5"}
/>
<text
x={pad.l - 8}
y={y + 4}
textAnchor="end"
fontSize="11"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{fmt(y0 + span * g)}
</text>
</g>
)
})}
<path d={areaD} style={{ fill: stroke, fillOpacity: 0.12 }} />
<path
d={lineD}
fill="none"
style={{ stroke }}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
{[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1]
.filter((i, idx, a) => a.indexOf(i) === idx)
.map((i) => (
<text
key={`x-${i}`}
x={xAt(i)}
y={H - 10}
textAnchor="middle"
fontSize="10"
fill="hsl(var(--muted-foreground))"
fontFamily="ui-monospace, monospace"
>
{i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"}
</text>
))}
</svg>
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
<span>
min <span className="font-mono text-foreground">{Math.round(minVal)}</span> мс
</span>
<span>
max <span className="font-mono text-foreground">{Math.round(maxVal)}</span> мс
</span>
{valid.length < 2 && (
<span className="text-amber-600 dark:text-amber-400">В ряду одна точка линия для наглядности продублирована.</span>
)}
</div>
</div>
)
}
function probeGroupActionKey(srvId: string, group: { name: string; target: string }) {
return `${srvId}\t${group.name}\t${group.target}`
}
// ── shared components ──────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
@@ -313,6 +444,11 @@ function ServerPickerCards({
<span className="text-muted-foreground/30"></span>
<span className="truncate">{server.host}</span>
</div>
{!server.enabled && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
В инвентаре выключен для ping всё равно можно выбрать, если бекенд достигает REST API.
</p>
)}
</button>
)
})
@@ -553,6 +689,8 @@ function LinkedFilterPickerCards({
interface ServerResource {
serverId: string
/** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */
hasData?: boolean
cpu: number
cpuHistory: number[]
ramUsed: number // MB
@@ -577,6 +715,24 @@ interface BackendServer {
os: string | null
}
function mapBackendServersToServers(data: BackendServer[]): Server[] {
return data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
}
interface SpeedTestRun {
id: string
startedAt: number
@@ -620,6 +776,25 @@ interface SpeedProbeRow {
lastPingError?: string | null
}
/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */
function orphanSpeedSourceStub(id: string): Server {
return {
id,
name: `Нет в каталоге (#${id})`,
host: "—",
model: "—",
os: "—",
site: "—",
country: "UN",
asn: "",
type: "home-router",
enabled: false,
status: "offline",
latency: null,
sessions: 0,
}
}
function stripIpCidr(addr: string): string {
const t = addr.trim()
if (!t) return ""
@@ -663,7 +838,7 @@ function findLinkedSpeedProbe(
function fmtMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
return `${mb} МБ`
return `${mb.toFixed(1)} МБ`
}
function fmtUptime(sec: number): string {
@@ -714,6 +889,7 @@ const INIT_RESOURCES: ServerResource[] = mockServers.map(s => {
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
hasData: true,
}
})
@@ -747,30 +923,36 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey;
type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; serversList: Server[] }) {
function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) {
const [sortKey, setSortKey] = useState<ResSortKey>("name")
const [sortAsc, setSortAsc] = useState(true)
const [resSearch, setResSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
const rows = useMemo(() => resources.map(r => ({
...r,
server: serversList.find(s => s.id === r.serverId),
ramPct: Math.round(r.ramUsed / r.ramTotal * 100),
hddPct: Math.round(r.hddUsed / r.hddTotal * 100),
})).filter(r => r.server !== undefined), [resources, serversList])
const rows = useMemo(() => resources.map((r) => {
const hasData = r.hasData !== false
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
return {
...r,
hasData,
server: serversList.find(s => s.id === r.serverId),
ramPct,
hddPct,
}
}).filter(r => r.server !== undefined), [resources, serversList])
// KPI aggregates
const online = rows.filter(r => r.server!.status === "online")
const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0
const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hddPct >= 85).length
// KPI aggregates (только серверы с реальными сэмплами за окно)
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0
const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length
const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
// Alerts
const alerts = useMemo(() =>
rows.filter(r => r.server!.status === "online" && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)),
[rows],
)
@@ -998,7 +1180,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{visible.map(r => {
const srv = r.server!
const offline = srv.status !== "online"
const isCrit = !offline && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const hasSamples = r.hasData !== false
const noMetrics = offline || !hasSamples
const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
return (
<tr key={r.serverId} className={cn(
@@ -1016,21 +1200,26 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
<span className="font-mono font-semibold">{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
{!offline && r.hasData === false && (
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
нет данных
</span>
)}
</div>
</td>
{/* Board + ROS */}
<td className="px-4 py-3 hidden md:table-cell">
<div className="flex flex-col leading-tight">
<span className="font-mono text-xs text-muted-foreground">{r.boardName}</span>
<span className="font-mono text-xs text-muted-foreground">{hasSamples ? r.boardName : "—"}</span>
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
</div>
</td>
{/* CPU */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[140px]">
<div className="flex items-center gap-2">
@@ -1046,8 +1235,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* RAM */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1063,8 +1252,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* HDD */}
<td className="px-4 py-3">
{offline
? <span className="text-xs text-muted-foreground/30"></span>
{noMetrics
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
: (
<div className="flex flex-col gap-1.5 min-w-[155px]">
<div className="flex items-center justify-between text-xs">
@@ -1081,13 +1270,13 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
{/* Uptime */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-muted-foreground">
{offline ? "—" : fmtUptime(r.uptimeSeconds)}
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
</span>
</td>
{/* Temp */}
<td className="px-4 py-3">
{r.temp !== undefined && !offline ? (
{r.temp !== undefined && !noMetrics ? (
<span className={cn("font-mono text-sm font-semibold tabular-nums",
r.temp >= 70 ? "text-red-600 dark:text-red-400"
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
@@ -1108,7 +1297,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
</Card>
<p className="text-xs text-muted-foreground/40 text-center">
Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим
{liveApi
? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend"
: "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}
</p>
</div>
)
@@ -1118,8 +1309,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
export default function UptimePage() {
const [allServers, setAllServers] = useState<Server[]>(mockServers)
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
const liveApi = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
@@ -1132,6 +1324,11 @@ export default function UptimePage() {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [sheetOpen, setSheetOpen] = useState(false)
const [opError, setOpError] = useState<string | null>(null)
const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false)
/** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */
const [probeGroupPingBusy, setProbeGroupPingBusy] = useState<Record<string, boolean>>({})
/** Раскрытый подробный график RTT по id пробы */
const [probeRttChartOpen, setProbeRttChartOpen] = useState<Record<string, boolean>>({})
const [speedBusy, setSpeedBusy] = useState(false)
const [speedError, setSpeedError] = useState<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
@@ -1153,37 +1350,18 @@ export default function UptimePage() {
})
useEffect(() => {
if (!isLive) {
if (!liveApi) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAllServers(mockServers)
return
}
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped: Server[] = data.map((s) => ({
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: s.os ?? "—",
site: s.site || "—",
country: s.country || "UN",
asn: "",
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}))
setAllServers(mapped)
})
.catch(() => setAllServers([]))
}, [isLive, apiFetch])
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
}, [liveApi])
useEffect(() => {
if (isLive) return
if (liveApi) return
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
}, [isLive])
}, [liveApi])
const isPausedRef = useRef(isPaused)
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
@@ -1192,82 +1370,137 @@ export default function UptimePage() {
const overviewReqRef = useRef(0)
const loadLiveOverview = useCallback(async () => {
if (!isLive) return
if (!liveApi) return
const myReq = ++overviewReqRef.current
setOpError(null)
try {
const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h")
const [serverRows, data] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
])
if (myReq !== overviewReqRef.current) return
setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes)
setResources(data.resources)
} catch (e) {
if (myReq !== overviewReqRef.current) return
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
}
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const reloadSpeedData = useCallback(async () => {
if (!liveApi) return
type RunRow = {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}
try {
const [sp, runsRes] = await Promise.all([
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
])
setSpeedProbes(sp.probes ?? [])
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
} catch {
setSpeedProbes([])
setSpeedRuns([])
}
}, [apiFetch, liveApi])
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
if (!liveApi) return
if (opts?.showSpinner) setUptimeRefreshBusy(true)
let collectErr: string | null = null
try {
void checkBackend()
if (opts?.pollDevices) {
try {
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
} catch (e) {
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
}
}
await Promise.all([loadLiveOverview(), reloadSpeedData()])
if (collectErr) {
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
}
} finally {
if (opts?.showSpinner) setUptimeRefreshBusy(false)
}
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch])
const refreshProbeGroupPings = useCallback(
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
if (!liveApi) return
const key = probeGroupActionKey(srvId, group)
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
setOpError(null)
try {
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
method: "POST",
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
})
await loadLiveOverview()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
} finally {
setProbeGroupPingBusy((m) => {
const next = { ...m }
delete next[key]
return next
})
}
},
[liveApi, apiFetch, loadLiveOverview],
)
useEffect(() => {
if (!isLive) return
queueMicrotask(() => { void loadLiveOverview() })
}, [isLive, loadLiveOverview])
if (!liveApi) return
queueMicrotask(() => { void refreshUptimeLive() })
}, [liveApi, refreshUptimeLive])
useEffect(() => {
if (!isLive) return
void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
.then((data) => setSpeedProbes(data.probes ?? []))
.catch(() => setSpeedProbes([]))
}, [isLive, apiFetch])
useEffect(() => {
if (!isLive) return
void apiFetch<{
runs: Array<{
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "done" | "error"
error?: string | null
srcAddress?: string | null
dstAddress?: string | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}>
}>("/api/uptime/speed-test/runs")
.then((data) => {
const rows = (data.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
})
.catch(() => setSpeedRuns([]))
}, [isLive, apiFetch])
if (!liveApi || isPaused) return
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
return () => clearInterval(id)
}, [liveApi, isPaused, refreshUptimeLive])
// add-probe form
const [newSrcId, setNewSrcId] = useState("")
@@ -1279,13 +1512,11 @@ export default function UptimePage() {
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
const selectableSources = useMemo(
() => allServers.filter(s => s.enabled),
[allServers],
)
/** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */
const selectableSources = useMemo(() => allServers, [allServers])
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
if (!isLive) return
if (!liveApi) return
if (!serverId || speedIfaces[serverId]) return
const id = Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return
@@ -1295,7 +1526,7 @@ export default function UptimePage() {
} catch {
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
}
}, [apiFetch, isLive, speedIfaces])
}, [apiFetch, liveApi, speedIfaces])
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
useEffect(() => {
@@ -1333,7 +1564,7 @@ export default function UptimePage() {
setNewSrcInterface("")
return
}
if (!isLive) {
if (!liveApi) {
setSrcInterfaces([])
setNewSrcInterface("")
return
@@ -1356,22 +1587,23 @@ export default function UptimePage() {
setNewSrcInterface("")
})
.finally(() => setSrcInterfacesBusy(false))
}, [sheetOpen, newSrcId, isLive, apiFetch])
}, [sheetOpen, newSrcId, liveApi, apiFetch])
// servers that have at least one probe (preserve data-order)
const probedServerIds = useMemo(
() => [...new Set(probes.map(p => p.srcServerId))],
[probes],
)
const probedServers = useMemo(
() => allServers.filter(s => probedServerIds.includes(s.id)),
[probedServerIds, allServers],
)
const probedServers = useMemo(() => {
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
}, [probedServerIds, allServers])
// live RTT tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setProbes(prev => prev.map(p => {
if (!p.enabled || p.status === "down" || p.rtt === null) return p
@@ -1381,16 +1613,17 @@ export default function UptimePage() {
}))
}, 3000)
return () => clearInterval(id)
}, [isLive])
}, [liveApi])
// live resource tick
useEffect(() => {
const id = setInterval(() => {
if (isLive) return
if (liveApi) return
if (isPausedRef.current) return
setResources(prev => prev.map(r => {
const srv = allServers.find(s => s.id === r.serverId)
if (!srv || srv.status !== "online") return r
if (r.hasData === false) return r
const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8)))
const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
const newTemp = r.temp !== undefined
@@ -1407,7 +1640,7 @@ export default function UptimePage() {
}))
}, 5000)
return () => clearInterval(id)
}, [isLive, allServers])
}, [liveApi, allServers])
// ── derived ──
const stats = useMemo(() => ({
@@ -1420,7 +1653,7 @@ export default function UptimePage() {
const alertCount = useMemo(() =>
resources.filter(r => {
const s = allServers.find(x => x.id === r.serverId)
if (!s || s.status !== "online") return false
if (!s || s.status !== "online" || r.hasData === false) return false
const ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
@@ -1467,16 +1700,19 @@ export default function UptimePage() {
arr.push(p)
map.set(p.srcServerId, arr)
}
return selectableSources
.filter((s) => map.has(s.id))
.map((s) => ({
server: s,
probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, selectableSources])
const ids = [...map.keys()].sort((a, b) => {
const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a)
const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b)
return (sa.host + sa.name).localeCompare(sb.host + sb.name)
})
return ids.map((id) => ({
server: allServers.find((s) => s.id === id) ?? orphanSpeedSourceStub(id),
probes: (map.get(id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
}))
}, [speedProbes, allServers])
const persistProbes = useCallback((rows: PingProbe[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/probes", {
method: "PUT",
body: JSON.stringify({
@@ -1492,17 +1728,17 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
const toggleDashboardStar = useCallback((id: string) => {
const cur = probes.find((p) => p.id === id)
if (!cur) return
const nextVal = !cur.showOnDashboard
if (isLive) {
if (liveApi) {
overviewReqRef.current += 1
}
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
if (isLive) {
if (liveApi) {
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -1526,10 +1762,10 @@ export default function UptimePage() {
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
}
}
}, [probes, isLive, apiFetch, loadLiveOverview])
}, [probes, liveApi, apiFetch, loadLiveOverview])
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
if (!isLive) return
if (!liveApi) return
void apiFetch("/api/uptime/speed-probes", {
method: "PUT",
body: JSON.stringify({
@@ -1551,7 +1787,7 @@ export default function UptimePage() {
})),
}),
}).catch(() => {})
}, [apiFetch, isLive])
}, [apiFetch, liveApi])
// ── actions ──
const toggleProbe = (id: string, v: boolean) =>
@@ -1562,7 +1798,7 @@ export default function UptimePage() {
})
const deleteProbe = (id: string) => {
if (!isLive) {
if (!liveApi) {
const s = readMockDashboardStarIds()
s.delete(id)
writeMockDashboardStarIds(s)
@@ -1747,7 +1983,7 @@ export default function UptimePage() {
lines: ["status: running..."],
}, ...prev].slice(0, 20))
try {
if (isLive) {
if (liveApi) {
const payload = {
runId,
probeId: probe.id,
@@ -1974,15 +2210,15 @@ export default function UptimePage() {
: <><PauseIcon className="size-4" />Пауза</>}
</Button>
<Button variant="outline" size="sm" onClick={() => {
if (isLive) {
void loadLiveOverview()
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
if (liveApi) {
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
return
}
setProbes(mockProbesWithSavedStars(INIT_PROBES))
setResources(INIT_RESOURCES)
}}>
<RefreshCwIcon className="size-4" />{isLive ? "Обновить" : "Сбросить"}
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
</Button>
{tab === "probes" && (
@@ -2004,9 +2240,11 @@ export default function UptimePage() {
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
{isLive
? "Live (backend)"
: "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
{liveApi
? (backendStatus === false
? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются"
: "Live (backend)")
: "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
</div>
)}
{isPaused && (
@@ -2066,7 +2304,7 @@ export default function UptimePage() {
<div className="flex-1 overflow-y-auto">
{/* ── resources tab ── */}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} />}
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} liveApi={liveApi} />}
{/* ── speed tab ── */}
{tab === "speed" && (
@@ -2552,13 +2790,30 @@ export default function UptimePage() {
{!isCollapsed && (
<>
<div className="divide-y divide-border/60">
{probeGroups.map((group) => (
{probeGroups.map((group) => {
const groupBusyKey = probeGroupActionKey(srv.id, group)
const groupBusy = !!probeGroupPingBusy[groupBusyKey]
return (
<div key={`${group.name}|${group.target}`}>
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
<ArrowRightIcon className="size-3.5 text-muted-foreground/60" />
<span className="font-medium truncate">{group.name}</span>
<span className="font-mono text-muted-foreground">{group.target}</span>
<span className="ml-auto text-muted-foreground">{group.probes.length} интерф.</span>
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium truncate min-w-0">{group.name}</span>
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{group.probes.length} интерф.</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
disabled={!liveApi || groupBusy}
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
>
<RefreshCwIcon className={cn("size-3", groupBusy && "animate-spin")} />
Обновить
</Button>
</div>
</div>
<div className="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
@@ -2579,7 +2834,8 @@ export default function UptimePage() {
{group.probes.map((p) => {
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
return (
<div key={p.id}
<div key={p.id} className="border-b border-border/40 last:border-b-0">
<div
className={cn(
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!p.enabled && "opacity-40",
@@ -2675,6 +2931,41 @@ export default function UptimePage() {
</Button>
</div>
</div>
<Collapsible
open={probeRttChartOpen[p.id] ?? false}
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 bg-muted/15 px-4 py-1.5 text-left text-xs text-muted-foreground",
"outline-none hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
probeRttChartOpen[p.id] && "rotate-180",
)}
/>
<span>
Подробный график RTT
<span className="font-mono tabular-nums text-muted-foreground/80 ml-1">
({p.series.length} точ.)
</span>
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/50 bg-muted/5 px-4 py-3">
<ProbePingRttDetailChart
series={p.series}
status={p.status}
probeName={p.name}
target={group.target}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
@@ -2691,7 +2982,8 @@ export default function UptimePage() {
Добавить интерфейс для {group.name} ({group.target})
</button>
</div>
))}
)
})}
</div>
</>
)}
@@ -2894,9 +3186,12 @@ export default function UptimePage() {
</div>
</div>
<Field label="Источник (кто пингует)">
<Field
label="Источник (кто пингует)"
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
>
<ServerPickerCards
options={allServers.filter((s) => s.enabled)}
options={selectableSources}
selectedId={newSrcId}
onSelect={(id) => setNewSrcId(id)}
/>
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
+6
View File
@@ -0,0 +1,6 @@
/**
* Порог среднего RTT (мс) для ICMP-проб: выше статус `warn` при потерях 1%.
* 60 мс было слишком жёстко для WAN/CDN (например ~6580 мс до 1.1.1.1 из РФ нормальная география).
* Фронт: держите то же значение в `lib/ping-probe.ts`.
*/
export const PING_PROBE_WARN_RTT_MS = 120
+248
View File
@@ -1,4 +1,6 @@
import Database from "better-sqlite3"
type SqliteHandle = InstanceType<typeof Database>
import { drizzle } from "drizzle-orm/better-sqlite3"
import { env } from "../config.js"
import * as schema from "./schema.js"
@@ -190,6 +192,60 @@ CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
ON uptime_speed_test_runs(created_at);
CREATE TABLE IF NOT EXISTS scheduler_runs (
id TEXT PRIMARY KEY,
job_key TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
duration_ms INTEGER NOT NULL DEFAULT 0,
result_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
ON scheduler_runs(job_key, finished_at);
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
interval_sec INTEGER NOT NULL DEFAULT 120,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
ok INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER,
error TEXT,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id
ON servers_rest_ping_samples(server_id, id DESC);
CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
target_label TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id
ON alert_gre_tunnel_samples(target_label, id DESC);
CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
peer_key TEXT NOT NULL,
state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id
ON alert_bgp_peer_samples(peer_key, id DESC);
CREATE TABLE IF NOT EXISTS evobgp_settings (
id INTEGER PRIMARY KEY,
base_url TEXT NOT NULL DEFAULT '',
@@ -197,6 +253,120 @@ CREATE TABLE IF NOT EXISTS evobgp_settings (
enabled INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
id INTEGER PRIMARY KEY,
bot_token TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
condition TEXT NOT NULL,
severity TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
cooldown TEXT NOT NULL DEFAULT '5м',
rule_chat_id TEXT NOT NULL DEFAULT '',
recovery_mode TEXT NOT NULL DEFAULT 'always',
recovery_stability_sec INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_history (
id TEXT PRIMARY KEY,
rule_id TEXT,
rule_name TEXT NOT NULL,
severity TEXT NOT NULL,
message TEXT NOT NULL,
sent_ok INTEGER NOT NULL DEFAULT 1,
fired_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
CREATE TABLE IF NOT EXISTS alert_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
combine_mode TEXT NOT NULL DEFAULT 'any',
enabled INTEGER NOT NULL DEFAULT 1,
cooldown_override TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_rule_targets (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
target TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
condition_line TEXT NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
CREATE TABLE IF NOT EXISTS alert_engine_state (
scope_key TEXT PRIMARY KEY,
last_fired_at TEXT NOT NULL DEFAULT '',
last_payload_hash TEXT
);
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
kind TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
rule_id TEXT PRIMARY KEY,
payload_hash TEXT NOT NULL,
since_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alert_destinations (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'telegram',
label TEXT NOT NULL DEFAULT '',
telegram_chat_id TEXT NOT NULL DEFAULT '',
message_thread_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS alert_outbox (
id TEXT PRIMARY KEY,
dedupe_key TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'telegram',
status TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
next_attempt_at TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt
ON alert_outbox(status, next_attempt_at);
CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe
ON alert_outbox(dedupe_key);
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
id INTEGER PRIMARY KEY,
last_source_finished_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
// Lightweight schema evolution for existing databases without migrations
@@ -215,6 +385,10 @@ const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on
if (!hasShowOnDashboardColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
}
const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec")
if (!hasProbeIntervalSecColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`)
}
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
@@ -226,6 +400,16 @@ if (!hasSpeedIntervalColumn) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
}
const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled")
if (!hasUptimeJobFlags) {
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`)
sqlite.exec(
`UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`,
)
}
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
const ensureSpeedProbeCol = (name: string, ddl: string) => {
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
@@ -240,6 +424,11 @@ ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD C
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }>
if (!schedulerRunCols.some((c) => c.name === "result_json")) {
sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`)
}
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
if (!serverCols.some((c) => c.name === "lan_subnet")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
@@ -248,6 +437,44 @@ if (!serverCols.some((c) => c.name === "wan_uplinks")) {
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
}
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`)
}
const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }>
if (!alertRulesCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`)
}
if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_mode")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`)
}
if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) {
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`)
}
const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }>
if (!alertHistoryCols.some((c) => c.name === "group_id")) {
sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`)
}
/** Одна строка target на правило из legacy-колонки `alert_rules.target` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index)
SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets)
`)
/** Одна строка условия из legacy `alert_rules.condition` */
sqlite.exec(`
INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index)
SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules
WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions)
`)
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 30, 14
@@ -266,4 +493,25 @@ SELECT 1, '', '', 0
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO servers_api_ping_settings (id, enabled, interval_sec)
SELECT 1, 0, 120
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
SELECT 1, '', ''
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
SELECT 1, NULL
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
`)
export const db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
export const sqliteDatabase: SqliteHandle = sqlite
+206
View File
@@ -115,6 +115,46 @@ export const trafficSettings = sqliteTable("traffic_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Настройки фоновой проверки доступности RouterOS REST API по серверам каталога. */
export const serversApiPingSettings = sqliteTable("servers_api_ping_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
intervalSec: integer("interval_sec").notNull().default(120),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Сырые пробы REST `/system/identity` по серверам — для UI сбора и для `buildSignalSnapshot` (алерты). */
export const serversRestPingSamples = sqliteTable("servers_rest_ping_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms"),
error: text("error"),
})
/** Сырые снимки GRE для алертов — пишет джоба `gre_bgp`. */
export const alertGreTunnelSamples = sqliteTable("alert_gre_tunnel_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
targetLabel: text("target_label").notNull(),
status: text("status").notNull(),
})
/** Сырые снимки BGP-сессий для алертов — пишет джоба `gre_bgp`. */
export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
sampledAt: text("sampled_at").notNull(),
peerKey: text("peer_key").notNull(),
state: text("state").notNull(),
})
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficSamples = sqliteTable("traffic_samples", {
@@ -136,7 +176,11 @@ export const trafficSamples = sqliteTable("traffic_samples", {
export const uptimeSettings = sqliteTable("uptime_settings", {
id: integer("id").primaryKey(),
/** Устаревший агрегат: синхронизируется как resources ping speed (для совместимости). */
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
resourcesEnabled: integer("resources_enabled", { mode: "boolean" }).notNull().default(true),
pingEnabled: integer("ping_enabled", { mode: "boolean" }).notNull().default(true),
speedEnabled: integer("speed_enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(15),
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
@@ -158,6 +202,8 @@ export const uptimeProbes = sqliteTable("uptime_probes", {
target: text("target").notNull(),
probeFilter: text("probe_filter").notNull().default("—"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** 0 — брать глобальный probe_interval_sec из uptime_settings */
intervalSec: integer("interval_sec").notNull().default(0),
/** Выводить пробу в блоке «Активные пробы» на дашборде */
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
@@ -231,6 +277,155 @@ export const evobgpSettings = sqliteTable("evobgp_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Единственная строка id=1: токен и чат Telegram для оповещений. */
export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
id: integer("id").primaryKey(),
botToken: text("bot_token").notNull().default(""),
chatId: text("chat_id").notNull().default(""),
/** Тема супергруппы (forum): `message_thread_id` в Bot API; NULL — общий чат */
messageThreadId: integer("message_thread_id"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
export const alertGroups = sqliteTable("alert_groups", {
id: text("id").primaryKey(),
name: text("name").notNull(),
combineMode: text("combine_mode", { enum: ["any", "all"] }).notNull().default("any"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
/** Переопределение cooldown для агрегата группы; NULL — по умолчанию 5м */
cooldownOverride: text("cooldown_override", {
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
}),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Правила оповещений (UI /alerts). */
export const alertRules = sqliteTable("alert_rules", {
id: text("id").primaryKey(),
name: text("name").notNull(),
type: text("type", {
enum: ["gre-tunnel", "bgp-peer", "bgp-prefix", "gre-client", "server", "rtt", "loss", "traffic"],
}).notNull(),
/** Сводная подпись (первый target или join); для совместимости и поиска */
target: text("target").notNull(),
condition: text("condition").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldown: text("cooldown", {
enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"],
}).notNull().default("5м"),
chatId: text("rule_chat_id").notNull().default(""),
/** NULL / 0 — выкл. Секунды: уведомление только если условие держится столько времени без «отмены» (восстановление сигнала). */
confirmStabilitySec: integer("confirm_stability_sec"),
/** Политика recovery-уведомления для правила. */
recoveryMode: text("recovery_mode", {
enum: ["always", "never", "conditional"],
}).notNull().default("always"),
/** Доп. задержка подтверждения для recovery при `conditional`; NULL — без доп. ожидания. */
recoveryStabilitySec: integer("recovery_stability_sec"),
/** NULL — правило вне группы (отдельные отправки по cooldown правила) */
groupId: text("group_id"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Несколько объектов на правило; срабатывание по OR (хотя бы один). */
export const alertRuleTargets = sqliteTable("alert_rule_targets", {
id: text("id").primaryKey(),
ruleId: text("rule_id").notNull(),
target: text("target").notNull(),
sortIndex: integer("sort_index").notNull().default(0),
})
/** Несколько условий на правило; срабатывание по OR (любое из выбранных). */
export const alertRuleConditions = sqliteTable("alert_rule_conditions", {
id: text("id").primaryKey(),
ruleId: text("rule_id").notNull(),
conditionLine: text("condition_line").notNull(),
sortIndex: integer("sort_index").notNull().default(0),
})
/** Состояние движка: cooldown / дедуп по ключу rule:id или group:id */
export const alertEngineState = sqliteTable("alert_engine_state", {
scopeKey: text("scope_key").primaryKey(),
lastFiredAt: text("last_fired_at").notNull().default(""),
lastPayloadHash: text("last_payload_hash"),
})
/** Снимок прошлого тика для live-сигналов (GRE/BGP): переходы «offline» / «восстановился». */
export const alertEnginePrevLive = sqliteTable("alert_engine_prev_live", {
kind: text("kind", { enum: ["gre", "bgp"] }).primaryKey(),
payloadJson: text("payload_json").notNull().default("{}"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Ожидание стабильности срабатывания по правилу (антидребезг). */
export const alertEngineConfirmPending = sqliteTable("alert_engine_confirm_pending", {
ruleId: text("rule_id").primaryKey(),
payloadHash: text("payload_hash").notNull(),
sinceAt: text("since_at").notNull(),
})
/** Задел: несколько получателей Telegram (пока UI не подключён) */
export const alertDestinations = sqliteTable("alert_destinations", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["telegram"] }).notNull().default("telegram"),
label: text("label").notNull().default(""),
telegramChatId: text("telegram_chat_id").notNull().default(""),
messageThreadId: integer("message_thread_id"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
})
/** Журнал отправок / срабатываний (персист + при необходимости пополняется воркером). */
export const alertHistory = sqliteTable("alert_history", {
id: text("id").primaryKey(),
ruleId: text("rule_id"),
groupId: text("group_id"),
ruleName: text("rule_name").notNull(),
severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(),
message: text("message").notNull(),
sentOk: integer("sent_ok", { mode: "boolean" }).notNull().default(true),
firedAt: text("fired_at").notNull(),
})
/** Outbox доставки уведомлений: ретраи и идемпотентность отправки каналов. */
export const alertOutbox = sqliteTable("alert_outbox", {
id: text("id").primaryKey(),
dedupeKey: text("dedupe_key").notNull(),
channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"),
status: text("status", { enum: ["pending", "sent", "failed"] }).notNull().default("pending"),
retryCount: integer("retry_count").notNull().default(0),
maxRetries: integer("max_retries").notNull().default(3),
nextAttemptAt:text("next_attempt_at").notNull(),
payloadJson: text("payload_json").notNull(),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
sentAt: text("sent_at"),
lastError: text("last_error"),
})
/** Cursor движка: watermark последней обработанной точки snapshot-источников. */
export const alertEngineCursor = sqliteTable("alert_engine_cursor", {
id: integer("id").primaryKey(),
lastSourceFinishedAt: text("last_source_finished_at"),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
/** Журнал прогонов планировщика (аудит). */
export const schedulerRuns = sqliteTable("scheduler_runs", {
id: text("id").primaryKey(),
jobKey: text("job_key").notNull(),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at").notNull(),
status: text("status", { enum: ["ok", "error"] }).notNull(),
error: text("error"),
durationMs: integer("duration_ms").notNull().default(0),
/** JSON: см. `SchedulerRunSnapshot` в types/scheduler-run-snapshot.ts */
resultJson: text("result_json"),
})
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
id: text("id").primaryKey(),
probeId: text("probe_id"),
@@ -264,6 +459,7 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
@@ -271,4 +467,14 @@ export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
export type AlertGroupRow = typeof alertGroups.$inferSelect
export type AlertRuleRow = typeof alertRules.$inferSelect
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
export type AlertEngineStateRow = typeof alertEngineState.$inferSelect
export type AlertDestinationRow = typeof alertDestinations.$inferSelect
export type AlertHistoryRow = typeof alertHistory.$inferSelect
export type AlertOutboxRow = typeof alertOutbox.$inferSelect
export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect
+11 -8
View File
@@ -9,12 +9,15 @@ import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import serversApiPingRoutes from "./routes/servers-api-ping.js"
import uptimeRoutes from "./routes/uptime.js"
import networkRoutes from "./routes/network.js"
import evobgpRoutes from "./routes/evobgp.js"
import probesRoutes from "./routes/probes.js"
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
import schedulerRoutes from "./routes/scheduler.js"
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
import alertsRoutes from "./routes/alerts.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
@@ -49,18 +52,18 @@ await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
restartTrafficCollector()
void collectTrafficOnce()
restartUptimeCollector()
void collectUptimeOnce()
refreshScheduler()
app.addHook("onClose", async () => {
stopTrafficCollector()
stopUptimeCollector()
stopScheduler()
})
// ── start ──────────────────────────────────────────────────────────────────────
+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,
}
})
@@ -0,0 +1,48 @@
/**
* Внутренние хуки: после записи сигналов в SQLite (коллекторы) отложенный прогон `alert_engine`.
* Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`.
*
* Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать
* `scheduleAlertEngineAfterDataCollectors()`.
*/
import { isSchedulerJobRunning } from "./scheduler-running.js"
const ALERT_AFTER_COLLECT_DEBOUNCE_MS = 250
const ALERT_AFTER_COLLECT_WAIT_MS = 100
let alertAfterCollectTimer: ReturnType<typeof setTimeout> | null = null
let alertAfterCollectChain: Promise<void> = Promise.resolve()
let alertEngineRunner: (() => Promise<void>) | null = null
/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */
export function wireAlertEngineRunner(run: () => Promise<void>): void {
alertEngineRunner = run
}
export function clearAlertCollectorHooksTimer(): void {
if (alertAfterCollectTimer != null) {
clearTimeout(alertAfterCollectTimer)
alertAfterCollectTimer = null
}
}
async function runAlertEngineWhenIdle(): Promise<void> {
const run = alertEngineRunner
if (!run) return
while (isSchedulerJobRunning("alert_engine")) {
await new Promise<void>((resolve) => setTimeout(resolve, ALERT_AFTER_COLLECT_WAIT_MS))
}
await run()
}
/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */
export function scheduleAlertEngineAfterDataCollectors(): void {
if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer)
alertAfterCollectTimer = setTimeout(() => {
alertAfterCollectTimer = null
alertAfterCollectChain = alertAfterCollectChain
.then(() => runAlertEngineWhenIdle())
.catch(() => {})
}, ALERT_AFTER_COLLECT_DEBOUNCE_MS)
}
@@ -0,0 +1,71 @@
import assert from "node:assert/strict"
import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js"
const ruleId = "test-confirm-stability-recovery-bypass"
deleteConfirmPending(ruleId)
const baseRule: ApiAlertRule = {
id: ruleId,
name: "Server transition",
type: "server",
target: "core-1",
targets: ["core-1"],
groupId: null,
condition: "перешёл в offline · восстановился",
conditions: ["перешёл в offline", "восстановился"],
severity: "critical",
enabled: true,
cooldown: "5м",
confirmStabilitySec: 120,
recoveryMode: "always",
recoveryStabilitySec: null,
chatId: "",
lastFiredAt: null,
}
const problemHit = {
ruleId,
ruleName: baseRule.name,
severity: baseRule.severity,
message: "Сервер: core-1 — перешёл в offline",
transition: "problem" as const,
payloadHash: "problem-1",
}
const recoveryHit = {
ruleId,
ruleName: baseRule.name,
severity: baseRule.severity,
message: "Сервер: core-1 — восстановился",
transition: "recovery" as const,
payloadHash: "recovery-1",
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
assert.equal(m.get(ruleId), false, "без hit отправка не готова")
const left = db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
assert.equal(left, undefined, "pending должен очищаться после отсутствия hit")
}
console.log("alert-engine confirm-stability tests ok")
@@ -0,0 +1,82 @@
import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import type { RuleEvalHit } from "./types.js"
export function deleteConfirmPending(ruleId: string) {
db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId)).run()
}
/**
* Обновляет состояние ожидания стабильности и возвращает, можно ли отправлять уведомление в этом тике.
* При отсутствии срабатывания (`hit == null`) ожидание сбрасывается.
*/
export function stabilityReadyForSend(
ruleId: string,
hit: RuleEvalHit | null,
delaySec: number | null | undefined,
): boolean {
if (!hit) {
deleteConfirmPending(ruleId)
return false
}
const sec = delaySec != null && delaySec > 0 ? Math.floor(delaySec) : 0
if (sec <= 0) {
deleteConfirmPending(ruleId)
return true
}
const now = Date.now()
const nowIso = new Date().toISOString()
const existing = db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
if (!existing || existing.payloadHash !== hit.payloadHash) {
if (existing) {
db.update(alertEngineConfirmPending)
.set({ payloadHash: hit.payloadHash, sinceAt: nowIso })
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.run()
} else {
db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso }).run()
}
return false
}
const t = Date.parse(existing.sinceAt)
if (!Number.isFinite(t)) return false
return now - t >= sec * 1000
}
/** После успешной отправки — сбросить таймер стабильности для следующего цикла. */
export function clearConfirmPendingAfterSend(ruleIds: string[]) {
for (const id of ruleIds) deleteConfirmPending(id)
}
export function computeStabilityReadyMap(
rules: ApiAlertRule[],
hitByRule: Map<string, RuleEvalHit | null>,
): Map<string, boolean> {
const m = new Map<string, boolean>()
for (const rule of rules) {
if (!rule.enabled) {
deleteConfirmPending(rule.id)
m.set(rule.id, false)
continue
}
const hit = hitByRule.get(rule.id) ?? null
const delaySec =
hit?.transition === "recovery"
? rule.recoveryMode === "conditional"
? rule.recoveryStabilitySec
: 0
: rule.confirmStabilitySec
m.set(rule.id, stabilityReadyForSend(rule.id, hit, delaySec))
}
return m
}
@@ -0,0 +1,22 @@
import type { ApiAlertRule } from "../alerts-service.js"
const COOLDOWN_MS: Record<ApiAlertRule["cooldown"], number> = {
"1м": 60_000,
"5м": 5 * 60_000,
"15м": 15 * 60_000,
"1ч": 60 * 60_000,
"4ч": 4 * 60 * 60_000,
"24ч": 24 * 60 * 60_000,
}
export function cooldownToMs(cd: ApiAlertRule["cooldown"]): number {
return COOLDOWN_MS[cd] ?? COOLDOWN_MS["5м"]
}
/** Можно ли отправить снова после lastIso (ISO) */
export function isCooldownElapsed(lastFiredIso: string | null | undefined, cooldown: ApiAlertRule["cooldown"]): boolean {
if (!lastFiredIso?.trim()) return true
const t = Date.parse(lastFiredIso)
if (!Number.isFinite(t)) return true
return Date.now() - t >= cooldownToMs(cooldown)
}
@@ -0,0 +1,136 @@
import type { ApiAlertGroup, ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap } from "./confirm-stability.js"
import { isCooldownElapsed } from "./cooldown.js"
import { groupShouldFire } from "./groups.js"
import { isPositiveRecoveryTelegramText } from "./telegram-emoji.js"
import { shouldAllowRecoveryByPolicy } from "./state-machine.js"
import type { AlertEngineRuleDiag, RuleEvalHit } from "./types.js"
export type EngineStateMap = Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>
export type StandaloneDecision = {
kind: "rule"
rule: ApiAlertRule
hit: RuleEvalHit
}
export type GroupDecision = {
kind: "group"
group: ApiAlertGroup
members: ApiAlertRule[]
hits: RuleEvalHit[]
}
function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string {
const suffix = transition ?? "neutral"
return `rule:${ruleId}:${suffix}`
}
function textAfterGroupAlertHeader(fullBody: string): string {
const segs = fullBody.split(/\n\n/)
if (segs.length >= 2 && /^Группа\s*«/i.test(segs[0] ?? "")) return segs.slice(1).join("\n\n").trim()
return fullBody
}
export function buildRuleDiagnostics(
rules: ApiAlertRule[],
hitByRule: Map<string, RuleEvalHit | null>,
stabilityReady: Map<string, boolean>,
state: EngineStateMap,
canSend: boolean,
): AlertEngineRuleDiag[] {
const out: AlertEngineRuleDiag[] = []
for (const rule of rules) {
if (!rule.enabled) continue
const hit = hitByRule.get(rule.id) ?? null
const inGroup = Boolean(rule.groupId)
const evalHit = Boolean(hit)
const stab = stabilityReady.get(rule.id) ?? false
const key = ruleScopeKey(rule.id, hit?.transition)
const prev = state.get(key)
const cooldownOk = isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)
let blocked: AlertEngineRuleDiag["blocked"]
if (!evalHit) blocked = "no_hit"
else if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) blocked = "stability"
else if (inGroup) blocked = "in_group"
else if (!stab) blocked = "stability"
else if (!cooldownOk) blocked = "cooldown"
else if (!canSend) blocked = "no_telegram"
else if (hit && isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) blocked = "dedupe_positive"
else blocked = undefined
out.push({
ruleId: rule.id,
inGroup,
evalHit,
hitTransition: hit?.transition,
hitMessage: hit?.message,
stabilityOk: stab,
cooldownOk,
telegramOk: canSend,
blocked,
})
}
return out
}
export function computeDecisions(args: {
rules: ApiAlertRule[]
groups: ApiAlertGroup[]
hitByRule: Map<string, RuleEvalHit | null>
state: EngineStateMap
canSend: boolean
}): {
stabilityReady: Map<string, boolean>
standalone: StandaloneDecision[]
grouped: GroupDecision[]
ruleDiag: AlertEngineRuleDiag[]
} {
const { rules, groups, hitByRule, state, canSend } = args
const stabilityReady = computeStabilityReadyMap(rules, hitByRule)
const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend)
const standalone: StandaloneDecision[] = []
const grouped: GroupDecision[] = []
for (const rule of rules) {
if (!rule.enabled || rule.groupId) continue
const hit = hitByRule.get(rule.id)
if (!hit) continue
if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) continue
if (!stabilityReady.get(rule.id)) continue
const key = ruleScopeKey(rule.id, hit.transition)
const prev = state.get(key)
if (!isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)) continue
if (isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) continue
standalone.push({ kind: "rule", rule, hit })
}
for (const g of groups) {
if (!g.enabled) continue
const members = rules.filter((r) => r.groupId === g.id && r.enabled)
if (members.length === 0) continue
const firing = members.map((r) => {
const h = hitByRule.get(r.id)
if (!shouldAllowRecoveryByPolicy(h ?? null, r.recoveryMode)) return false
return Boolean(h) && Boolean(stabilityReady.get(r.id))
})
if (!groupShouldFire(g.combineMode, firing)) continue
const hits: RuleEvalHit[] = []
for (const member of members) {
const hit = hitByRule.get(member.id)
if (!hit) continue
if (!shouldAllowRecoveryByPolicy(hit, member.recoveryMode)) continue
hits.push(hit)
}
if (hits.length === 0) continue
const key = `group:${g.id}`
const prev = state.get(key)
const cd = g.cooldownOverride ?? "5м"
if (!isCooldownElapsed(prev?.lastFiredAt, cd)) continue
const body = `Группа «${g.name}» (${g.combineMode === "any" ? "ANY" : "ALL"})\n\n${hits.map((h) => h.message).join("\n")}`
const hash = hits.map((h) => h.payloadHash).sort().join("|")
if (isPositiveRecoveryTelegramText(textAfterGroupAlertHeader(body)) && prev?.lastPayloadHash === hash) continue
grouped.push({ kind: "group", group: g, members, hits })
}
return { stabilityReady, standalone, grouped, ruleDiag }
}
@@ -0,0 +1,227 @@
import assert from "node:assert/strict"
import type { ApiAlertRule } from "../alerts-service.js"
import { evaluateRule } from "./evaluate-rule.js"
import type { SignalSnapshot } from "./types.js"
function emptySnap(over: Partial<SignalSnapshot> = {}): SignalSnapshot {
return {
sampledAt: "t",
servers: [],
probes: [],
trafficByServer: [],
greTunnels: [],
bgpSessions: [],
...over,
}
}
function rule(p: Partial<ApiAlertRule> & Pick<ApiAlertRule, "id" | "name" | "type" | "targets">): ApiAlertRule {
return {
target: p.targets[0] ?? "",
groupId: null,
condition: p.conditions?.[0] ?? p.condition ?? "",
conditions: p.conditions ?? (p.condition ? [p.condition] : []),
severity: p.severity ?? "warning",
enabled: p.enabled ?? true,
cooldown: p.cooldown ?? "5м",
confirmStabilitySec: null,
recoveryMode: "always",
recoveryStabilitySec: null,
chatId: "",
lastFiredAt: null,
...p,
} as ApiAlertRule
}
{
const r = rule({
id: "g1",
name: "GRE",
type: "gre-tunnel",
targets: ["t1 / srv"],
conditions: ["перешёл в offline"],
})
const hit = evaluateRule(
r,
emptySnap({
greTunnels: [{ targetLabel: "t1 / srv", status: "down", prevStatus: "up" }],
}),
)
assert.ok(hit)
}
{
const r = rule({
id: "g2",
name: "GRE rec",
type: "gre-tunnel",
targets: ["t1 / srv"],
conditions: ["восстановился"],
})
const hit = evaluateRule(
r,
emptySnap({
greTunnels: [{ targetLabel: "t1 / srv", status: "up", prevStatus: "down" }],
}),
)
assert.ok(hit)
}
{
const r = rule({
id: "b1",
name: "BGP",
type: "bgp-peer",
targets: ["srv: peer1"],
conditions: ["разорвал сессию"],
})
const hit = evaluateRule(
r,
emptySnap({
bgpSessions: [{ key: "srv: peer1", state: "Idle", prevState: "Established" }],
}),
)
assert.ok(hit)
}
{
const r = rule({
id: "b2",
name: "BGP up",
type: "bgp-peer",
targets: ["srv: peer1"],
conditions: ["восстановил сессию"],
})
const hit = evaluateRule(
r,
emptySnap({
bgpSessions: [{ key: "srv: peer1", state: "Established", prevState: "Active" }],
}),
)
assert.ok(hit)
}
{
const r = rule({
id: "pfx",
name: "pfx",
type: "bgp-prefix",
targets: ["любой префикс"],
conditions: ["был отозван"],
})
assert.equal(evaluateRule(r, emptySnap()), null)
}
{
const r = rule({
id: "srv-rec",
name: "rec",
type: "server",
targets: ["core"],
conditions: ["восстановился"],
})
assert.ok(
evaluateRule(
r,
emptySnap({
servers: [
{
name: "core",
status: "online",
prevStatus: "offline",
prev2Status: "offline",
sampledAt: "t1",
},
],
}),
),
"один online после offline — тоже «восстановился» (ручной REST / ресурс уже online)",
)
const hit = evaluateRule(
r,
emptySnap({
servers: [
{
name: "core",
status: "online",
prevStatus: "online",
prev2Status: "offline",
sampledAt: "t2",
},
],
}),
)
assert.ok(hit, "два подряд online после offline — одно срабатывание")
assert.equal(hit?.transition, "recovery")
}
{
const r = rule({
id: "srv-rec-offline-phrase",
name: "rec-offline-phrase",
type: "server",
targets: ["core"],
conditions: ["восстановился после offline"],
})
const hit = evaluateRule(
r,
emptySnap({
servers: [
{
name: "core",
status: "online",
prevStatus: "online",
prev2Status: "offline",
sampledAt: "t2",
},
],
}),
)
assert.ok(hit, "в тексте условия есть «offline» — всё равно восстановление по сэмплам")
}
{
const r = rule({
id: "srv-off",
name: "off",
type: "server",
targets: ["core"],
conditions: ["перешёл в offline"],
})
const edgeHit = evaluateRule(
r,
emptySnap({
servers: [
{
name: "core",
status: "offline",
prevStatus: "online",
prev2Status: "online",
sampledAt: "t",
},
],
}),
)
assert.ok(edgeHit)
assert.equal(edgeHit?.transition, "problem")
assert.equal(
evaluateRule(
r,
emptySnap({
servers: [
{
name: "core",
status: "offline",
prevStatus: "offline",
prev2Status: "online",
sampledAt: "t",
},
],
}),
),
null,
"устойчивый offline — без повторного «перешёл» на каждом тике",
)
}
console.log("alert-engine evaluate-rule tests ok")
@@ -0,0 +1,323 @@
import { createHash } from "node:crypto"
import type { ApiAlertRule } from "../alerts-service.js"
import type { RuleEvalHit, SignalSnapshot } from "./types.js"
function hashPayload(parts: string[]): string {
return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 24)
}
function firstNumberInString(s: string): number | null {
const m = s.replace(",", ".").match(/(\d+(?:\.\d+)?)/)
if (!m) return null
const n = Number.parseFloat(m[1] ?? "")
return Number.isFinite(n) ? n : null
}
function maxSeverity(
a: ApiAlertRule["severity"],
b: ApiAlertRule["severity"],
): ApiAlertRule["severity"] {
const o = { critical: 3, warning: 2, info: 1 } as const
return o[a] >= o[b] ? a : b
}
function findServer(snap: SignalSnapshot, name: string) {
const n = name.trim().toLowerCase()
return snap.servers.find((s) => s.name.trim().toLowerCase() === n)
}
function findProbe(snap: SignalSnapshot, targetKey: string) {
const k = targetKey.trim()
return snap.probes.find((p) => p.key.trim() === k)
}
function findTraffic(snap: SignalSnapshot, serverName: string) {
const n = serverName.trim().toLowerCase()
return snap.trafficByServer.find((t) => t.serverName.trim().toLowerCase() === n)
}
function findGreTunnel(snap: SignalSnapshot, targetLabel: string) {
const n = targetLabel.trim()
return snap.greTunnels.find((g) => g.targetLabel.trim() === n)
}
function findBgpPeer(snap: SignalSnapshot, key: string) {
const n = key.trim()
return snap.bgpSessions.find((b) => b.key.trim() === n)
}
function isBgpEstablished(state: string): boolean {
return state.trim().toLowerCase() === "established"
}
/** Заголовок для Telegram: не «…недоступен: … — восстановился». */
function formatServerAlertTitle(ruleName: string, wantRecovery: boolean): string {
if (!wantRecovery) return ruleName
if (!/недоступен/i.test(ruleName)) return ruleName
const trimmed = ruleName.replace(/\s*,\s*недоступен\s*$/i, "").replace(/\s+недоступен\s*$/i, "").trim()
if (!trimmed || trimmed === ruleName) return ruleName
return `${trimmed} — снова в сети`
}
function evalServerLike(
rule: ApiAlertRule,
snap: SignalSnapshot,
cond: string,
): RuleEvalHit | null {
const c = cond.toLowerCase()
const isOtkluchilsya = /отключился|отключилась|отключилось/.test(c)
/** «Перешёл в …» — только на границе, без спама пока сэмплы остаются offline. */
const impliesOfflineEdge = /переш(ё|е)л/.test(c) && (/offline|офлайн/.test(c) || /недоступен/.test(c))
const impliesDegradedEdge = /переш(ё|е)л/.test(c) && c.includes("degraded")
const wantOffline =
c.includes("offline") || c.includes("офлайн") || c.includes("отключ")
const wantDegraded = c.includes("degraded")
/** «Восстановился» / «подключился»: двойной online в сэмплах (см. тесты). Не отсекаем по слову «offline» в фразе вроде «после offline». */
const wantRecovery =
(c.includes("восстанов") || c.includes("подключ")) && !isOtkluchilsya
const hits: string[] = []
for (const tgt of rule.targets) {
const s = findServer(snap, tgt)
if (!s) continue
if (wantRecovery) {
const p2 = s.prev2Status
if (
s.status === "online" &&
s.prevStatus === "online" &&
p2 != null &&
(p2 === "offline" || p2 === "degraded")
) {
hits.push(`${tgt}`)
} else if (
s.status === "online" &&
(s.prevStatus === "offline" || s.prevStatus === "degraded")
) {
/** Один свежий online после offline (например ресурс уже online, REST только подтвердил). */
hits.push(`${tgt}`)
}
} else if ((impliesOfflineEdge || isOtkluchilsya) && s.status === "offline") {
if (s.prevStatus === null || s.prevStatus === "online") hits.push(tgt)
} else if (impliesDegradedEdge && s.status === "degraded") {
if (s.prevStatus === null || s.prevStatus === "online") hits.push(tgt)
} else if (wantDegraded && s.status === "degraded" && !impliesDegradedEdge) {
hits.push(tgt)
} else if (
wantOffline &&
s.status === "offline" &&
!impliesOfflineEdge &&
!isOtkluchilsya
) {
hits.push(tgt)
}
}
if (hits.length === 0) return null
const title = formatServerAlertTitle(rule.name, wantRecovery)
const msg = `${title}: ${hits.join(", ")}${cond}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: wantRecovery ? "recovery" : "problem",
payloadHash: hashPayload([rule.id, "server", hits.sort().join(","), cond]),
}
}
function evalRtt(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null {
const threshold = firstNumberInString(cond)
const wantLow = /<|ниже|меньше/i.test(cond) && !/>|выше|больше/i.test(cond)
const wantHigh = />|выше|больше/i.test(cond)
const hits: string[] = []
for (const tgt of rule.targets) {
const p = findProbe(snap, tgt)
if (!p) continue
if (p.status === "down" || p.status === "warn") {
hits.push(`${tgt} (${p.status}, RTT ${p.rttMs ?? "—"} мс)`)
continue
}
if (p.rttMs == null) continue
if (wantHigh && threshold != null && p.rttMs > threshold) hits.push(`${tgt} (${p.rttMs} мс)`)
if (wantLow && threshold != null && p.rttMs < threshold) hits.push(`${tgt} (${p.rttMs} мс)`)
}
if (hits.length === 0) return null
const msg = `${rule.name}: RTT ${cond}${hits.join("; ")}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: "neutral",
payloadHash: hashPayload([rule.id, "rtt", hits.sort().join(";"), cond]),
}
}
function evalLoss(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null {
const threshold = firstNumberInString(cond)
if (threshold == null) return null
const hits: string[] = []
for (const tgt of rule.targets) {
const p = findProbe(snap, tgt)
if (!p || p.lossPct == null) continue
if (p.lossPct > threshold) hits.push(`${tgt} (${p.lossPct}%)`)
}
if (hits.length === 0) return null
const msg = `${rule.name}: потери ${cond}${hits.join("; ")}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: "neutral",
payloadHash: hashPayload([rule.id, "loss", hits.sort().join(";"), cond]),
}
}
function evalGreTunnel(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null {
const lc = cond.toLowerCase()
const wantOffline = lc.includes("offline") && !lc.includes("восстанов")
const wantRecovery =
lc.includes("восстанов") && !/offline|отключ/i.test(lc)
const hits: string[] = []
for (const tgt of rule.targets) {
const g = findGreTunnel(snap, tgt)
if (!g) continue
if (wantRecovery) {
const prev = g.prevStatus
if (
g.status === "up" &&
prev != null &&
(prev === "down" || prev === "degraded")
) {
hits.push(tgt)
}
} else if (wantOffline && g.status === "down") {
hits.push(tgt)
}
}
if (hits.length === 0) return null
const msg = `${rule.name}: ${hits.join(", ")}${cond}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: wantRecovery ? "recovery" : "problem",
payloadHash: hashPayload([rule.id, "gre-tunnel", hits.sort().join(","), cond]),
}
}
function evalBgpPeer(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null {
const lc = cond.toLowerCase()
const wantDown = lc.includes("разорвал")
const wantUp = lc.includes("восстановил")
const hits: string[] = []
for (const tgt of rule.targets) {
const b = findBgpPeer(snap, tgt)
if (!b) continue
if (wantDown && !isBgpEstablished(b.state)) hits.push(tgt)
if (
wantUp &&
isBgpEstablished(b.state) &&
b.prevState != null &&
String(b.prevState).trim() !== "" &&
!isBgpEstablished(b.prevState)
) {
hits.push(tgt)
}
}
if (hits.length === 0) return null
const msg = `${rule.name}: ${hits.join(", ")}${cond}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: wantUp ? "recovery" : "problem",
payloadHash: hashPayload([rule.id, "bgp-peer", hits.sort().join(","), cond]),
}
}
function evalTraffic(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null {
const threshold = firstNumberInString(cond)
if (threshold == null) return null
const rxLow = /RX\s*</i.test(cond)
const txLow = /TX\s*</i.test(cond)
const hits: string[] = []
for (const tgt of rule.targets) {
const t = findTraffic(snap, tgt)
if (!t) continue
if (rxLow && t.rxMbps < threshold) hits.push(`${tgt} RX ${t.rxMbps.toFixed(2)} Мбит/с`)
if (txLow && t.txMbps < threshold) hits.push(`${tgt} TX ${t.txMbps.toFixed(2)} Мбит/с`)
}
if (hits.length === 0) return null
const msg = `${rule.name}: трафик ${cond}${hits.join("; ")}`
return {
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
message: msg,
transition: "neutral",
payloadHash: hashPayload([rule.id, "traffic", hits.sort().join(";"), cond]),
}
}
function evaluateRuleWithCondition(
rule: ApiAlertRule,
snap: SignalSnapshot,
cond: string,
): RuleEvalHit | null {
const c = cond.trim()
if (!c) return null
switch (rule.type) {
case "server":
case "gre-client":
return evalServerLike(rule, snap, c)
case "rtt":
return evalRtt(rule, snap, c)
case "loss":
return evalLoss(rule, snap, c)
case "traffic":
return evalTraffic(rule, snap, c)
case "gre-tunnel":
return evalGreTunnel(rule, snap, c)
case "bgp-peer":
return evalBgpPeer(rule, snap, c)
case "bgp-prefix":
return null
default:
return null
}
}
/** Одно правило: OR по targets; OR по conditions (любое из выбранных). */
export function evaluateRule(rule: ApiAlertRule, snap: SignalSnapshot): RuleEvalHit | null {
if (!rule.enabled || rule.targets.length === 0) return null
const conds =
rule.conditions?.length > 0
? rule.conditions.map((c) => c.trim()).filter(Boolean)
: [rule.condition.trim()].filter(Boolean)
if (conds.length === 0) return null
const hits: RuleEvalHit[] = []
for (const cond of conds) {
const h = evaluateRuleWithCondition(rule, snap, cond)
if (h) hits.push(h)
}
if (hits.length === 0) return null
if (hits.length === 1) return hits[0]!
const message = hits.map((h) => h.message).join("\n")
const severity = hits.reduce((a, h) => maxSeverity(a, h.severity), hits[0]!.severity)
const payloadHash = hashPayload([rule.id, "or-conds", ...hits.map((h) => h.payloadHash).sort()])
return {
ruleId: rule.id,
ruleName: rule.name,
severity,
message,
transition: hits.some((h) => h.transition === "problem")
? "problem"
: hits.some((h) => h.transition === "recovery")
? "recovery"
: "neutral",
payloadHash,
}
}
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import { groupShouldFire } from "./groups.js"
import { cooldownToMs, isCooldownElapsed } from "./cooldown.js"
assert.equal(groupShouldFire("any", []), false)
assert.equal(groupShouldFire("any", [false, false]), false)
assert.equal(groupShouldFire("any", [false, true]), true)
assert.equal(groupShouldFire("all", [true, true]), true)
assert.equal(groupShouldFire("all", [true, false]), false)
assert.equal(groupShouldFire("all", [false, false]), false)
assert.equal(cooldownToMs("1м"), 60_000)
assert.equal(isCooldownElapsed(null, "5м"), true)
assert.equal(isCooldownElapsed(new Date(Date.now() - 6 * 60_000).toISOString(), "5м"), true)
assert.equal(isCooldownElapsed(new Date(Date.now() - 2 * 60_000).toISOString(), "5м"), false)
console.log("alert-engine groups/cooldown tests ok")
@@ -0,0 +1,9 @@
/**
* Логика группы: ANY = хотя бы один член «в огне»; ALL = все члены «в огне».
* `memberFiring` только включённые правила группы, по порядку.
*/
export function groupShouldFire(combineMode: "any" | "all", memberFiring: boolean[]): boolean {
if (memberFiring.length === 0) return false
if (combineMode === "any") return memberFiring.some(Boolean)
return memberFiring.every(Boolean)
}
+138
View File
@@ -0,0 +1,138 @@
import { and, asc, eq, lte } from "drizzle-orm"
import { db } from "../../db/index.js"
import { alertOutbox } from "../../db/schema.js"
import { appendAlertHistory, sendTelegramAlertMessage } from "../alerts-service.js"
export type AlertOutboxPayload = {
text: string
chatId?: string
history: {
id: string
ruleId?: string | null
groupId?: string | null
ruleName: string
severity: "critical" | "warning" | "info"
message: string
firedAt: string
}
}
function safeParsePayload(raw: string): AlertOutboxPayload | null {
try {
return JSON.parse(raw) as AlertOutboxPayload
} catch {
return null
}
}
export function enqueueTelegramOutbox(input: {
id: string
dedupeKey: string
payload: AlertOutboxPayload
maxRetries?: number
}): boolean {
const existing = db
.select()
.from(alertOutbox)
.where(eq(alertOutbox.dedupeKey, input.dedupeKey))
.limit(1)
.all()[0]
if (existing && (existing.status === "pending" || existing.status === "sent")) return false
const nowIso = new Date().toISOString()
db.insert(alertOutbox)
.values({
id: input.id,
dedupeKey: input.dedupeKey,
channel: "telegram",
status: "pending",
retryCount: 0,
maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)),
nextAttemptAt: nowIso,
payloadJson: JSON.stringify(input.payload),
})
.run()
return true
}
export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> {
return (async () => {
const nowIso = new Date().toISOString()
const rows = db
.select()
.from(alertOutbox)
.where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso)))
.orderBy(asc(alertOutbox.createdAt))
.limit(Math.max(1, limit))
.all()
let sent = 0
let failed = 0
const errors: string[] = []
for (const row of rows) {
const payload = safeParsePayload(row.payloadJson)
if (!payload) {
db.update(alertOutbox)
.set({
status: "failed",
lastError: "invalid payload_json",
})
.where(eq(alertOutbox.id, row.id))
.run()
failed += 1
continue
}
const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId })
if (send.ok) {
db.update(alertOutbox)
.set({
status: "sent",
sentAt: new Date().toISOString(),
lastError: null,
})
.where(eq(alertOutbox.id, row.id))
.run()
appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
ruleName: payload.history.ruleName,
severity: payload.history.severity,
message: payload.history.message,
sentOk: true,
firedAt: payload.history.firedAt,
})
sent += 1
continue
}
const nextRetry = row.retryCount + 1
const exhausted = nextRetry >= row.maxRetries
db.update(alertOutbox)
.set({
retryCount: nextRetry,
status: exhausted ? "failed" : "pending",
nextAttemptAt: new Date(Date.now() + Math.min(300_000, nextRetry * 15_000)).toISOString(),
lastError: send.error,
})
.where(eq(alertOutbox.id, row.id))
.run()
if (exhausted) {
appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
ruleName: payload.history.ruleName,
severity: payload.history.severity,
message: payload.history.message,
sentOk: false,
firedAt: payload.history.firedAt,
})
failed += 1
} else {
errors.push(`outbox ${row.id}: ${send.error}`)
}
}
return { sent, failed, errors }
})()
}
@@ -0,0 +1,47 @@
import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { alertEnginePrevLive } from "../../db/schema.js"
export type PrevLiveKind = "gre" | "bgp"
/** Предыдущие строковые состояния (GRE: up|down|degraded; BGP: state как с роутера). */
export type PrevLiveStringMap = Record<string, string>
function safeParseMap(json: string | null | undefined): PrevLiveStringMap {
if (!json || json.trim() === "") return {}
try {
const v = JSON.parse(json) as unknown
if (v == null || typeof v !== "object" || Array.isArray(v)) return {}
return v as PrevLiveStringMap
} catch {
return {}
}
}
export function loadPrevLiveMap(kind: PrevLiveKind): PrevLiveStringMap {
const row = db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
return safeParseMap(row?.payloadJson)
}
export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
const payloadJson = JSON.stringify(map)
const existing = db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
if (existing) {
db.update(alertEnginePrevLive)
.set({ payloadJson, updatedAt: new Date().toISOString() })
.where(eq(alertEnginePrevLive.kind, kind))
.run()
} else {
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
}
}
@@ -0,0 +1,20 @@
import type { ApiAlertRule } from "../alerts-service.js"
import { evaluateRule } from "./evaluate-rule.js"
import type { RuleEvalHit, SignalSnapshot } from "./types.js"
export function matchRules(rules: ApiAlertRule[], snap: SignalSnapshot): {
hitByRule: Map<string, RuleEvalHit | null>
errors: string[]
} {
const errors: string[] = []
const hitByRule = new Map<string, RuleEvalHit | null>()
for (const rule of rules) {
try {
hitByRule.set(rule.id, evaluateRule(rule, snap))
} catch (e) {
errors.push(`rule ${rule.id}: ${e instanceof Error ? e.message : String(e)}`)
hitByRule.set(rule.id, null)
}
}
return { hitByRule, errors }
}
@@ -0,0 +1,194 @@
import { db } from "../../db/index.js"
import { alertEngineState } from "../../db/schema.js"
import { eq } from "drizzle-orm"
import {
getTelegramPublic,
listAlertGroups,
listAlertRules,
type ApiAlertRule,
} from "../alerts-service.js"
import { getGreBgpSnapshotCollectorState } from "../gre-bgp-snapshot-collector.js"
import { isAnyAlertSnapshotSourceJobRunning } from "../scheduler-running.js"
import { getServersRestPingCollectorState } from "../servers-rest-ping-collector.js"
import { isTrafficCollecting } from "../traffic-collector.js"
import { getUptimeCollectorsState } from "../uptime-collector.js"
import { computeDecisions } from "./decision-engine.js"
import { clearConfirmPendingAfterSend } from "./confirm-stability.js"
import { dispatchPendingOutbox, enqueueTelegramOutbox } from "./outbox.js"
import { matchRules } from "./rule-matcher.js"
import { ingestAlertSignals } from "./signal-ingestor.js"
import { getLatestSourceFinishedAt, getSourceWatermark, updateSourceWatermark } from "./source-watermark.js"
import { pickTelegramAlertEmoji } from "./telegram-emoji.js"
import type { AlertEngineRunResult, RuleEvalHit } from "./types.js"
const SNAPSHOT_SOURCE_WAIT_MS = 30_000
const SNAPSHOT_SOURCE_POLL_MS = 20
/**
* Не строить снимок, пока коллекторы пишут в SQLite иначе гонка с `alert_engine` по таймеру
* (в т.ч. слот планировщика занят до первого `await` в коллекторе, когда внутренний `collecting` ещё false).
*/
async function awaitSnapshotSourcesIdle(): Promise<void> {
const t0 = Date.now()
while (Date.now() - t0 < SNAPSHOT_SOURCE_WAIT_MS) {
const busy =
isAnyAlertSnapshotSourceJobRunning() ||
getServersRestPingCollectorState().running ||
getGreBgpSnapshotCollectorState().running ||
getUptimeCollectorsState().resources ||
getUptimeCollectorsState().ping ||
isTrafficCollecting()
if (!busy) return
await new Promise<void>((r) => setTimeout(r, SNAPSHOT_SOURCE_POLL_MS))
}
}
function newHistoryId(): string {
return `ah-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function newOutboxId(): string {
return `ao-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHash: string | null }> {
const rows = db.select().from(alertEngineState).all()
const m = new Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>()
for (const r of rows) {
m.set(r.scopeKey, { lastFiredAt: r.lastFiredAt, lastPayloadHash: r.lastPayloadHash ?? null })
}
return m
}
function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
const existing = db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1).all()[0]
if (existing) {
db.update(alertEngineState)
.set({ lastFiredAt, lastPayloadHash })
.where(eq(alertEngineState.scopeKey, scopeKey))
.run()
} else {
db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash }).run()
}
}
function severityRank(s: ApiAlertRule["severity"]): number {
if (s === "critical") return 3
if (s === "warning") return 2
return 1
}
function maxSeverity(a: ApiAlertRule["severity"], b: ApiAlertRule["severity"]): ApiAlertRule["severity"] {
return severityRank(a) >= severityRank(b) ? a : b
}
function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string {
return `rule:${ruleId}:${transition ?? "neutral"}`
}
/** Один проход движка: оценка правил, группы, Telegram, история, состояние cooldown. */
export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
const errors: string[] = []
const sampledAt = new Date().toISOString()
const latestSourceFinishedAt = getLatestSourceFinishedAt()
const prevSourceFinishedAt = getSourceWatermark()
const hasNewSources =
latestSourceFinishedAt != null &&
(prevSourceFinishedAt == null || latestSourceFinishedAt > prevSourceFinishedAt)
const rules = listAlertRules()
const groups = listAlertGroups()
const state = loadEngineStateMap()
const pub = getTelegramPublic()
const canSend = pub.tokenConfigured && Boolean(pub.chatId?.trim())
let standaloneFires = 0
let groupFires = 0
let ruleDiag: AlertEngineRunResult["ruleDiag"] = []
if (hasNewSources) {
await awaitSnapshotSourcesIdle()
const snap = ingestAlertSignals()
const matched = matchRules(rules, snap)
errors.push(...matched.errors)
const { standalone, grouped, ruleDiag: diag } = computeDecisions({
rules,
groups,
hitByRule: matched.hitByRule,
state,
canSend,
})
ruleDiag = diag
for (const d of standalone) {
if (!canSend) break
const firedAt = new Date().toISOString()
const key = ruleScopeKey(d.rule.id, d.hit.transition)
const queued = enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${d.hit.payloadHash}`,
payload: {
text: `${pickTelegramAlertEmoji(d.hit.message)} ${d.hit.message}`,
chatId: d.rule.chatId?.trim() || undefined,
history: {
id: newHistoryId(),
ruleId: d.rule.id,
groupId: null,
ruleName: d.hit.ruleName,
severity: d.hit.severity,
message: d.hit.message,
firedAt,
},
},
})
if (!queued) continue
standaloneFires += 1
clearConfirmPendingAfterSend([d.rule.id])
upsertEngineState(key, firedAt, d.hit.payloadHash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: d.hit.payloadHash })
}
for (const d of grouped) {
if (!canSend) break
const firedAt = new Date().toISOString()
const lines = d.hits.map((h) => h.message)
const sev = d.hits.reduce((a, h) => maxSeverity(a, h.severity), d.hits[0]!.severity)
const body = `Группа «${d.group.name}» (${d.group.combineMode === "any" ? "ANY" : "ALL"})\n\n${lines.join("\n")}`
const hash = d.hits.map((h) => h.payloadHash).sort().join("|")
const key = `group:${d.group.id}`
const queued = enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${hash}`,
payload: {
text: `${pickTelegramAlertEmoji(body)} ${body}`,
history: {
id: newHistoryId(),
ruleId: null,
groupId: d.group.id,
ruleName: `Группа: ${d.group.name}`,
severity: sev,
message: body,
firedAt,
},
},
})
if (!queued) continue
groupFires += 1
upsertEngineState(key, firedAt, hash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: hash })
clearConfirmPendingAfterSend(d.members.map((r) => r.id))
}
updateSourceWatermark(latestSourceFinishedAt)
}
const outbox = await dispatchPendingOutbox()
errors.push(...outbox.errors)
return {
sampledAt,
rulesChecked: hasNewSources ? rules.length : 0,
standaloneFires,
groupFires,
skippedNoTelegram: !canSend,
errors,
ruleDiag: ruleDiag ?? [],
}
}
@@ -0,0 +1,6 @@
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
/** Единая точка входа для подготовки сигналов к rule matching. */
export function ingestAlertSignals() {
return buildSignalSnapshotFromCollectors()
}
@@ -0,0 +1,265 @@
import { sqliteDatabase } from "../../db/index.js"
import type {
GreBgpSnapshotRunSnapshot,
PingRunSnapshot,
ResourcesRunSnapshot,
SchedulerRunSnapshot,
ServersRestPingRunSnapshot,
TrafficRunSnapshot,
} from "../../types/scheduler-run-snapshot.js"
import type {
BgpPeerSignal,
GreTunnelSignal,
ProbeSignal,
ServerSignal,
SignalSnapshot,
TrafficServerSignal,
} from "./types.js"
function normalizeNameKey(v: string): string {
return v.trim().toLowerCase()
}
function safeParseRunSnapshot(raw: string | null): SchedulerRunSnapshot | null {
if (!raw) return null
try {
return JSON.parse(raw) as SchedulerRunSnapshot
} catch {
return null
}
}
function readLatestOkRunSnapshots(jobKey: string, limit = 32): SchedulerRunSnapshot[] {
const rows = sqliteDatabase
.prepare(
`SELECT result_json AS resultJson
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
)
.all(jobKey, limit) as { resultJson: string | null }[]
const out: SchedulerRunSnapshot[] = []
for (const row of rows) {
const parsed = safeParseRunSnapshot(row.resultJson)
if (!parsed || parsed.job !== jobKey) continue
out.push(parsed)
}
return out
}
type TriState = "online" | "offline" | "degraded"
function timelineToServerSignal(name: string, sampledAt: string, statuses: TriState[]): ServerSignal {
return {
name,
status: statuses[0] ?? "offline",
prevStatus: statuses[1] ?? null,
prev2Status: statuses[2] ?? null,
sampledAt,
}
}
function collectServerSignalsFromResourceRuns(runs: ResourcesRunSnapshot[]): Map<string, ServerSignal> {
const byKey = new Map<
string,
{ name: string; sampledAt: string; statuses: TriState[] }
>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] }
if (rec.statuses.length < 3) rec.statuses.push(s.status === "online" ? "online" : "offline")
if (!byKey.has(key)) rec.sampledAt = run.sampledAt
byKey.set(key, rec)
}
}
const out = new Map<string, ServerSignal>()
for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses))
return out
}
function collectServerSignalsFromRestRuns(runs: ServersRestPingRunSnapshot[]): Map<string, ServerSignal> {
const byKey = new Map<
string,
{ name: string; sampledAt: string; statuses: TriState[] }
>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] }
if (rec.statuses.length < 3) rec.statuses.push(s.ok ? "online" : "offline")
if (!byKey.has(key)) rec.sampledAt = run.sampledAt
byKey.set(key, rec)
}
}
const out = new Map<string, ServerSignal>()
for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses))
return out
}
function mergeResourceAndRestServer(resource: ServerSignal, rest: ServerSignal): ServerSignal {
const rUp = resource.status === "online"
const tUp = rest.status === "online"
if (tUp && !rUp) {
const rBad = resource.status === "offline" || resource.status === "degraded"
if (rBad) {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.status,
sampledAt: rest.sampledAt,
}
}
return rest
}
if (rUp && !tUp) return resource
if (tUp && rUp) {
if (rest.sampledAt >= resource.sampledAt) {
if (resource.prevStatus === "offline" || resource.prevStatus === "degraded") {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.prevStatus === "degraded" ? "degraded" : "offline",
sampledAt: rest.sampledAt,
}
}
if (
resource.prevStatus === "online" &&
resource.prev2Status != null &&
(resource.prev2Status === "offline" || resource.prev2Status === "degraded")
) {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.prev2Status,
sampledAt: rest.sampledAt,
}
}
}
return rest.sampledAt >= resource.sampledAt ? rest : resource
}
return rest.sampledAt >= resource.sampledAt ? rest : resource
}
function collectProbeSignalsFromRuns(runs: PingRunSnapshot[]): ProbeSignal[] {
const out = new Map<string, ProbeSignal>()
for (const run of runs) {
for (const p of run.probes ?? []) {
const key = `${String(p.name ?? "").trim()}${String(p.target ?? "").trim()}`
if (!key.trim() || out.has(key)) continue
out.set(key, {
key,
status: p.status,
rttMs: p.rttMs ?? null,
lossPct: p.lossPct ?? null,
sampledAt: run.sampledAt,
})
}
}
return [...out.values()]
}
function collectTrafficSignalsFromRuns(runs: TrafficRunSnapshot[]): TrafficServerSignal[] {
const out = new Map<string, TrafficServerSignal>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
if (out.has(key)) continue
out.set(key, {
serverName: name,
rxMbps: Number(s.sumRxMbps ?? 0),
txMbps: Number(s.sumTxMbps ?? 0),
sampledAt: run.sampledAt,
})
}
}
return [...out.values()]
}
function collectGreSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): GreTunnelSignal[] {
const byTarget = new Map<string, GreTunnelSignal>()
for (const run of runs) {
const greRows = run.greTunnels ?? []
for (const row of greRows) {
const label = String(row.targetLabel ?? "").trim()
if (!label) continue
const prev = byTarget.get(label)
if (!prev) {
byTarget.set(label, {
targetLabel: label,
status: row.status,
prevStatus: null,
})
continue
}
if (prev.prevStatus == null) prev.prevStatus = row.status
}
}
return [...byTarget.values()]
}
function collectBgpSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): BgpPeerSignal[] {
const byKey = new Map<string, BgpPeerSignal>()
for (const run of runs) {
const peers = run.bgpPeers ?? []
for (const row of peers) {
const key = String(row.key ?? "").trim()
if (!key) continue
const prev = byKey.get(key)
if (!prev) {
byKey.set(key, { key, state: String(row.state ?? "").trim(), prevState: null })
continue
}
if (prev.prevState == null) prev.prevState = String(row.state ?? "").trim()
}
}
return [...byKey.values()]
}
/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */
export function buildSignalSnapshotFromCollectors(): SignalSnapshot {
const sampledAt = new Date().toISOString()
const resourceRuns = readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns)
const restByKey = collectServerSignalsFromRestRuns(restRuns)
const serverKeys = new Set([...resourceByKey.keys(), ...restByKey.keys()])
const servers: ServerSignal[] = []
for (const key of serverKeys) {
const resource = resourceByKey.get(key)
const rest = restByKey.get(key)
if (!rest) {
if (resource) servers.push(resource)
continue
}
if (!resource) {
servers.push(rest)
continue
}
servers.push(mergeResourceAndRestServer(resource, rest))
}
servers.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))
return {
sampledAt,
servers,
probes: collectProbeSignalsFromRuns(pingRuns),
trafficByServer: collectTrafficSignalsFromRuns(trafficRuns),
greTunnels: collectGreSignalsFromRuns(greBgpRuns),
bgpSessions: collectBgpSignalsFromRuns(greBgpRuns),
}
}
@@ -0,0 +1,7 @@
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
import type { SignalSnapshot } from "./types.js"
/** Legacy-совместимость: единый источник сигналов — collector snapshots из scheduler_runs. */
export function buildSignalSnapshot(): SignalSnapshot {
return buildSignalSnapshotFromCollectors()
}
@@ -0,0 +1,54 @@
import { desc, eq, inArray } from "drizzle-orm"
import { db } from "../../db/index.js"
import { alertEngineCursor, schedulerRuns } from "../../db/schema.js"
const SOURCE_JOBS = [
"traffic",
"servers_rest_ping",
"uptime_resources",
"uptime_ping",
"uptime_speed",
"gre_bgp",
] as const
export function getLatestSourceFinishedAt(): string | null {
const row = db
.select({ finishedAt: schedulerRuns.finishedAt })
.from(schedulerRuns)
.where(inArray(schedulerRuns.jobKey, [...SOURCE_JOBS]))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(1)
.all()[0]
return row?.finishedAt ?? null
}
export function getSourceWatermark(): string | null {
const row = db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1).all()[0]
return row?.lastSourceFinishedAt ?? null
}
export function updateSourceWatermark(lastSourceFinishedAt: string | null): void {
const existing = db
.select()
.from(alertEngineCursor)
.where(eq(alertEngineCursor.id, 1))
.limit(1)
.all()[0]
if (existing) {
db.update(alertEngineCursor)
.set({
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.where(eq(alertEngineCursor.id, 1))
.run()
return
}
db.insert(alertEngineCursor)
.values({
id: 1,
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.run()
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict"
import { shouldAllowRecoveryByPolicy } from "./state-machine.js"
const recoveryHit = {
ruleId: "r",
ruleName: "r",
severity: "warning" as const,
message: "восстановился",
transition: "recovery" as const,
payloadHash: "h1",
}
const problemHit = {
...recoveryHit,
transition: "problem" as const,
}
assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "always"), true)
assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "conditional"), true)
assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "never"), false)
assert.equal(shouldAllowRecoveryByPolicy(problemHit, "never"), true)
console.log("alert-engine state-machine tests ok")
@@ -0,0 +1,16 @@
import type { RuleEvalHit } from "./types.js"
export type RecoveryMode = "always" | "never" | "conditional"
export function isRecoveryTransition(hit: RuleEvalHit | null): boolean {
return hit?.transition === "recovery"
}
export function shouldAllowRecoveryByPolicy(
hit: RuleEvalHit | null,
policy: RecoveryMode,
): boolean {
if (!isRecoveryTransition(hit)) return true
if (policy === "never") return false
return true
}
@@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import { isPositiveRecoveryTelegramText, pickTelegramAlertEmoji } from "./telegram-emoji.js"
assert.equal(pickTelegramAlertEmoji("Сервер: x — снова в сети: x — восстановился"), "✅")
assert.equal(pickTelegramAlertEmoji("Сервер: x недоступен: x — перешёл в offline"), "🚨")
assert.equal(isPositiveRecoveryTelegramText("A — восстановился"), true)
assert.equal(isPositiveRecoveryTelegramText("A — перешёл в offline"), false)
assert.equal(isPositiveRecoveryTelegramText("BGP: p — восстановил сессию"), true)
console.log("telegram-emoji tests ok")
@@ -0,0 +1,27 @@
/**
* Эмодзи в Telegram: восстановление / «подключился» / BGP session up не сирена.
* Текст совпадает с форматом `evaluate-rule` (`… — условие`, несколько строк через \\n).
*/
function isRecoveryLine(line: string): boolean {
const t = line.toLowerCase()
return (
/[—\-]\s*(восстанов|подключ)/i.test(t) ||
/восстановил[аи]?\s+сессию/i.test(t) ||
/снова\s+в\s+сети/i.test(t)
)
}
/** «Хорошие» переходы: не слать дубль при том же payloadHash (см. `run-once`). */
export function isPositiveRecoveryTelegramText(fullText: string): boolean {
const lines = fullText
.split(/\n+/)
.map((l) => l.trim())
.filter(Boolean)
if (lines.length === 0) return false
if (lines.length === 1) return isRecoveryLine(lines[0]!)
return lines.every(isRecoveryLine)
}
export function pickTelegramAlertEmoji(fullText: string): string {
return isPositiveRecoveryTelegramText(fullText) ? "✅" : "🚨"
}
@@ -0,0 +1,77 @@
import type { ApiAlertRule } from "../alerts-service.js"
export type ServerSignal = {
name: string
status: "online" | "offline" | "degraded"
/** Предпоследний сэмпл (для условий «восстановился» / «подключился») */
prevStatus: "online" | "offline" | "degraded" | null
/** Третий с конца сэмпл — чтобы «восстановился» не висел истинным до второго online-сэмпла в БД. */
prev2Status: "online" | "offline" | "degraded" | null
sampledAt: string
}
export type ProbeSignal = {
/** Как в UI: `имя пробы → target` */
key: string
status: "up" | "warn" | "down"
rttMs: number | null
lossPct: number | null
sampledAt: string
}
export type TrafficServerSignal = {
serverName: string
rxMbps: number
txMbps: number
sampledAt: string
}
export type GreTunnelSignal = {
/** Как в UI: `tunnelName / serverName` */
targetLabel: string
status: "up" | "down" | "degraded"
/** Состояние на прошлом тике движка (из БД), для «восстановился». */
prevStatus: "up" | "down" | "degraded" | null
}
export type BgpPeerSignal = {
/** Как в UI: `serverName: peerName` */
key: string
state: string
/** Состояние на прошлом тике (строка state с роутера). */
prevState: string | null
}
export type SignalSnapshot = {
sampledAt: string
servers: ServerSignal[]
probes: ProbeSignal[]
trafficByServer: TrafficServerSignal[]
greTunnels: GreTunnelSignal[]
bgpSessions: BgpPeerSignal[]
}
export type RuleEvalHit = {
ruleId: string
ruleName: string
severity: ApiAlertRule["severity"]
message: string
/** Тип перехода для диагностики и логики задержки отправки. */
transition: "problem" | "recovery" | "neutral"
/** Для дедупа */
payloadHash: string
}
import type { AlertEngineRuleDiagSnapshot } from "../../types/scheduler-run-snapshot.js"
export type AlertEngineRuleDiag = AlertEngineRuleDiagSnapshot
export type AlertEngineRunResult = {
sampledAt: string
rulesChecked: number
standaloneFires: number
groupFires: number
skippedNoTelegram: boolean
errors: string[]
ruleDiag: AlertEngineRuleDiag[]
}
+593
View File
@@ -0,0 +1,593 @@
import { asc, desc, eq, sql } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import {
alertGroups,
alertHistory,
alertRuleConditions,
alertRuleTargets,
alertRules,
alertTelegramSettings,
servers,
uptimeProbes,
} from "../db/schema.js"
export type ApiAlertGroup = {
id: string
name: string
combineMode: "any" | "all"
enabled: boolean
cooldownOverride: (typeof alertRules.$inferSelect)["cooldown"] | null
}
export type ApiAlertRule = {
id: string
name: string
type: (typeof alertRules.$inferSelect)["type"]
/** Сводная строка для списка / поиска */
target: string
/** Все объекты правила (OR) */
targets: string[]
groupId: string | null
/** Сводная строка условий для списка */
condition: string
/** Все условия (OR); если пусто в БД — используется `condition` */
conditions: string[]
severity: (typeof alertRules.$inferSelect)["severity"]
enabled: boolean
cooldown: (typeof alertRules.$inferSelect)["cooldown"]
/** Секунды стабильности перед отправкой; null — выключено */
confirmStabilitySec: number | null
/** Политика отправки recovery для этого правила. */
recoveryMode: "always" | "never" | "conditional"
/** Доп. задержка подтверждения recovery при `conditional`. */
recoveryStabilitySec: number | null
chatId: string
lastFiredAt: string | null
}
export type ApiAlertHistoryEntry = {
id: string
ruleName: string
severity: (typeof alertHistory.$inferSelect)["severity"]
message: string
sent: boolean
firedAt: string
source: "db" | "live"
}
function ensureTelegramRow() {
let row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
if (!row) {
db.insert(alertTelegramSettings).values({ id: 1 }).run()
row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
}
return row!
}
function lastFiredByRuleId(): Map<string, string> {
const rows = sqliteDatabase
.prepare(
`SELECT rule_id AS ruleId, MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
.all() as { ruleId: string; mx: string }[]
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
}
return m
}
function targetsByRuleId(): Map<string, string[]> {
const rows = db
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
const arr = m.get(rid) ?? []
arr.push(t.target)
m.set(rid, arr)
}
return m
}
function conditionsByRuleId(): Map<string, string[]> {
const rows = db
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
const arr = m.get(rid) ?? []
arr.push(t.conditionLine)
m.set(rid, arr)
}
return m
}
export function listAlertGroups(): ApiAlertGroup[] {
return db
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
.all()
.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
}
export function listAlertRules(): ApiAlertRule[] {
const lf = lastFiredByRuleId()
const tgtMap = targetsByRuleId()
const condMap = conditionsByRuleId()
const rows = db.select().from(alertRules).all()
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
const condLines = condMap.get(r.id)
const cs = condLines?.length ? condLines : [r.condition]
return {
id: r.id,
name: r.name,
type: r.type,
target: r.target,
targets: ts,
groupId: r.groupId ?? null,
condition: r.condition,
conditions: cs,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec:
r.confirmStabilitySec != null && r.confirmStabilitySec > 0 ? r.confirmStabilitySec : null,
recoveryMode:
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always",
recoveryStabilitySec:
r.recoveryStabilitySec != null && r.recoveryStabilitySec > 0 ? r.recoveryStabilitySec : null,
chatId: r.chatId ?? "",
lastFiredAt: lf.get(r.id) ?? null,
}
})
}
function summarizeTargets(targets: string[]): string {
const u = targets.map((t) => t.trim()).filter(Boolean)
if (u.length === 0) return ""
if (u.length <= 2) return u.join(", ")
return `${u[0]}, ${u[1]} +${u.length - 2}`
}
function summarizeConditionLines(lines: string[]): string {
const u = lines.map((t) => t.trim()).filter(Boolean)
if (u.length === 0) return "—"
if (u.length === 1) return u[0]!
if (u.length <= 3) return u.join(" · ")
return `${u[0]} · ${u[1]} · +${u.length - 2}`
}
export type ReplaceAlertRuleInput = {
id: string
name: string
type: ApiAlertRule["type"]
/** @deprecated используйте targets; если нет targets — берётся target */
target?: string
targets?: string[]
/** Сводная строка; если не передана — из conditions */
condition?: string
/** Несколько условий (OR); если пусто — используется `condition` */
conditions?: string[]
severity: ApiAlertRule["severity"]
enabled: boolean
cooldown: ApiAlertRule["cooldown"]
/** Секунды; 0 / undefined / null — без задержки подтверждения */
confirmStabilitySec?: number | null
recoveryMode?: "always" | "never" | "conditional"
recoveryStabilitySec?: number | null
chatId: string
groupId?: string | null
}
export type ReplaceAlertGroupInput = {
id: string
name: string
combineMode: "any" | "all"
enabled: boolean
cooldownOverride: ApiAlertRule["cooldown"] | null
}
/** Атомарно заменяет группы, правила и строки targets. */
export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`(datetime('now'))`
const { groups, rules } = payload
db.transaction((tx) => {
tx.delete(alertRuleConditions).run()
tx.delete(alertRuleTargets).run()
tx.delete(alertRules).run()
tx.delete(alertGroups).run()
for (const g of groups) {
tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
}).run()
}
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
}).run()
rawTargets.forEach((t, i) => {
tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
}).run()
})
rawConds.forEach((c, i) => {
tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
}).run()
})
}
})
}
/** Совместимость: только правила, группы не трогаем. */
export function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = listAlertGroups()
replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
}
export function getTelegramSettingsRow() {
return ensureTelegramRow()
}
export function getTelegramPublic() {
const row = ensureTelegramRow()
const tid = row.messageThreadId
const messageThreadId =
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
return {
chatId: row.chatId ?? "",
tokenConfigured: Boolean(row.botToken?.trim()),
messageThreadId,
}
}
export function updateTelegramSettings(patch: {
token?: string | null
chatId?: string | undefined
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
if (patch.token !== undefined) {
if (patch.token === null || patch.token === "") nextToken = ""
else nextToken = patch.token.trim()
}
if (patch.chatId !== undefined) nextChat = patch.chatId.trim()
if (patch.messageThreadId !== undefined) {
if (patch.messageThreadId === null) nextThread = null
else {
const n = Math.floor(patch.messageThreadId)
nextThread = Number.isFinite(n) && n >= 1 ? n : null
}
}
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`(datetime('now'))`,
}
if (nextThread !== undefined) {
db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
.run()
} else {
db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1)).run()
}
return getTelegramPublic()
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
export function getTelegramBotToken(): string {
return ensureTelegramRow().botToken?.trim() ?? ""
}
/** Для `sendMessage`: только если в БД задана валидная тема. */
export function getTelegramMessageThreadIdForApi(): number | undefined {
const { messageThreadId } = getTelegramPublic()
return messageThreadId ?? undefined
}
/** Текст тестового сообщения по полям правила (как в UI предпросмотра, без записи в историю). */
export function formatAlertRuleTestTelegramText(opts: {
name: string
targets: string[]
conditionLine: string
severity: "critical" | "warning" | "info"
cooldown: string
}): string {
const emoji =
opts.severity === "critical" ? "🔴" : opts.severity === "warning" ? "🟡" : "🔵"
const obj = opts.targets.length ? opts.targets.join(", ") : "—"
return [
"🧪 MikroTik Manager — тестовая отправка по полям формы правила (в историю срабатываний не записывается).",
"",
`${emoji} ${opts.name}`,
`Объект: ${obj}`,
`Событие: ${opts.conditionLine}`,
`Cooldown: ${opts.cooldown}`,
].join("\n")
}
/** Отправка текста в Telegram (движок алертов и тест). */
export async function sendTelegramAlertMessage(opts: {
text: string
/** Если задан — вместо токена из БД (ручной тест) */
token?: string
/** Пусто — глобальный chat из БД */
chatId?: string
/** Переопределение темы; undefined — как в настройках */
messageThreadId?: number | null
}): Promise<{ ok: true } | { ok: false; error: string }> {
const token = (opts.token?.trim() || getTelegramBotToken()).trim()
if (!token) return { ok: false, error: "Не задан Bot Token" }
const pub = getTelegramPublic()
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
const threadId =
opts.messageThreadId !== undefined && opts.messageThreadId !== null
? opts.messageThreadId >= 1
? Math.floor(opts.messageThreadId)
: undefined
: getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
chat_id: chatId,
text: opts.text,
}
if (threadId != null) payload.message_thread_id = threadId
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!res.ok || data.ok === false) {
return { ok: false, error: data.description ?? res.statusText ?? "Telegram API error" }
}
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Ошибка сети" }
}
}
export function appendAlertHistory(entry: {
id: string
ruleId?: string | null
groupId?: string | null
ruleName: string
severity: (typeof alertHistory.$inferSelect)["severity"]
message: string
sentOk: boolean
firedAt?: string
}) {
const firedAt = entry.firedAt ?? new Date().toISOString()
db.insert(alertHistory)
.values({
id: entry.id,
ruleId: entry.ruleId ?? null,
groupId: entry.groupId ?? null,
ruleName: entry.ruleName,
severity: entry.severity,
message: entry.message,
sentOk: entry.sentOk,
firedAt,
})
.run()
}
interface LiveResRow {
serverName: string
sampledAt: string
}
interface LiveProbeRow {
probeName: string
target: string
sampledAt: string
status: string
rttMs: number | null
lossPct: number | null
}
function loadLiveResourceIssues(): LiveResRow[] {
return sqliteDatabase
.prepare(
`SELECT s.name AS serverName, urs.sampled_at AS sampledAt
FROM uptime_resource_samples urs
INNER JOIN (
SELECT server_id, MAX(id) AS mid FROM uptime_resource_samples GROUP BY server_id
) latest ON latest.mid = urs.id
INNER JOIN servers s ON s.id = urs.server_id
WHERE urs.status = 'offline'`,
)
.all() as LiveResRow[]
}
function loadLiveProbeIssues(): LiveProbeRow[] {
return sqliteDatabase
.prepare(
`SELECT p.name AS probeName, p.target AS target, ups.sampled_at AS sampledAt,
ups.status AS status, ups.rtt_ms AS rttMs, ups.loss_pct AS lossPct
FROM uptime_probe_samples ups
INNER JOIN (
SELECT probe_id, MAX(id) AS mid FROM uptime_probe_samples GROUP BY probe_id
) latest ON latest.mid = ups.id
INNER JOIN uptime_probes p ON p.id = ups.probe_id
WHERE ups.status IN ('down', 'warn')`,
)
.all() as LiveProbeRow[]
}
function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
const out: ApiAlertHistoryEntry[] = []
for (const r of loadLiveResourceIssues()) {
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
ruleName: "Сервер (ресурсы)",
severity: "critical",
message: `${r.serverName}: статус offline по данным мониторинга`,
sent: true,
firedAt: r.sampledAt,
source: "live",
})
}
for (const p of loadLiveProbeIssues()) {
const sev = p.status === "down" ? "critical" : "warning"
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
const safeId = `live-prb-${p.probeName}-${p.target}-${p.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
ruleName: "Ping-проба",
severity: sev,
message: `${p.probeName}${p.target}: ${p.status} (RTT ${rtt}, потери ${loss})`,
sent: true,
firedAt: p.sampledAt,
source: "live",
})
}
return out
}
export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
const rows = db
.select()
.from(alertHistory)
.orderBy(desc(alertHistory.firedAt))
.limit(limit)
.all()
return rows.map((h) => ({
id: h.id,
ruleName: h.ruleName,
severity: h.severity,
message: h.message,
sent: h.sentOk,
firedAt: h.firedAt,
source: "db" as const,
}))
}
export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHistoryEntry[] {
const persisted = listPersistedHistory(persistLimit)
const live = toLiveHistoryEntries().slice(0, liveCap)
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
const seen = new Set<string>()
const dedup: ApiAlertHistoryEntry[] = []
for (const e of merged) {
if (seen.has(e.id)) continue
seen.add(e.id)
dedup.push(e)
if (dedup.length >= 50) break
}
return dedup
}
export function getAlertsMeta() {
const srvRows = db
.select({
name: servers.name,
host: servers.host,
site: servers.site,
country: servers.country,
type: servers.type,
})
.from(servers)
.all()
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
const serversDetail = srvRows.map((r) => ({
name: r.name?.trim() || "",
host: r.host?.trim() || "",
site: r.site?.trim() || "",
country: r.country?.trim() || "",
type: (r.type ?? "home-router") as string,
}))
const probes = db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes).all()
const probeTargets = probes.map((p) => `${p.name}${p.target}`)
const defaults = ["8.8.8.8", "1.1.1.1"]
const rttLossTargets: string[] = []
for (const L of labels.slice(0, 12)) {
for (const d of defaults) rttLossTargets.push(`${L}${d}`)
}
return {
servers: labels,
greClients: labels,
probeTargets: probeTargets.length ? probeTargets : rttLossTargets,
rttLossTargets: rttLossTargets.length ? rttLossTargets : labels.map((L) => `${L} → 8.8.8.8`),
trafficServers: labels,
serversDetail,
}
}
+137
View File
@@ -0,0 +1,137 @@
import { servers } from "../db/schema.js"
import type { BgpSessionRead, RosBgpSession } 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
default:
break
}
}
return total
}
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)
}
function detectState(s: RosBgpSession): string {
if (s["established"] === "true") return "Established"
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]
for (const [k, v] of Object.entries(map)) {
if (key.startsWith(k)) return v
}
return key.charAt(0).toUpperCase() + key.slice(1)
}
if (s["uptime"]) return "Active"
return "Idle"
}
/** Парсинг BGP-сессий с роутера — общая логика для HTTP-маршрута и alert-engine. */
export function parseBgpSessions(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"
const localCaps = parseCapabilities(s["local.capabilities"])
const remoteCaps = parseCapabilities(s["remote.capabilities"])
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])]
const holdTime = parseDuration(s["hold-time"]) || parseInt(s["active-holdtime"] ?? "0") || 90
const keepalive = parseDuration(s["keepalive-time"]) || 30
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,
}
})
}
+30
View File
@@ -0,0 +1,30 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import type { BgpSessionRead } from "../types/server.js"
import { parseBgpSessions } from "./bgp-parse-sessions.js"
import { MikrotikClient } from "./mikrotik.js"
/** Как в UI `/alerts`: `serverName: peerName`. */
export function bgpPeerAlertKey(s: BgpSessionRead): string {
const sn = String(s.serverName ?? "").trim()
const nm = String(s.name ?? "").trim()
return `${sn}: ${nm}`.trim()
}
/** Агрегат BGP-сессий с включённых серверов — как `/api/bgp/sessions`. */
export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return parseBgpSessions(server, raw)
} catch {
return [] as BgpSessionRead[]
}
}),
)
return results.flat()
}
+25
View File
@@ -0,0 +1,25 @@
/** Серийный доступ к bandwidth-test по node id (пара узлов не пересекаются). */
const btestNodeLocks = new Map<number, Promise<void>>()
export 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]()
}
}
@@ -0,0 +1,100 @@
import { db, sqliteDatabase } from "../db/index.js"
import { alertBgpPeerSamples, alertGreTunnelSamples } from "../db/schema.js"
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
let collecting = false
export function getGreBgpSnapshotCollectorState(): { running: boolean } {
return { running: collecting }
}
/**
* Один опрос GRE + BGP по включённым серверам и запись строк в SQLite для `buildSignalSnapshot`.
* Движок оповещений больше не дублирует эти REST-запросы.
*/
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "gre_bgp",
sampledAt,
skipped: true,
greWritten: 0,
bgpWritten: 0,
errors: [],
}
}
collecting = true
const errors: string[] = []
const snapshot: GreBgpSnapshotRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "gre_bgp",
sampledAt,
greWritten: 0,
bgpWritten: 0,
greTunnels: [],
bgpPeers: [],
errors: [],
}
try {
const [greSettled, bgpSettled] = await Promise.allSettled([
fetchGreTunnelLiveRows(),
fetchBgpSessionsForAlerts(),
])
if (greSettled.status === "rejected") {
const msg =
greSettled.reason instanceof Error ? greSettled.reason.message : String(greSettled.reason)
errors.push(`GRE: ${msg}`)
}
if (bgpSettled.status === "rejected") {
const msg =
bgpSettled.reason instanceof Error ? bgpSettled.reason.message : String(bgpSettled.reason)
errors.push(`BGP: ${msg}`)
}
const greRows = greSettled.status === "fulfilled" ? greSettled.value : []
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
db.transaction((tx) => {
for (const r of greRows) {
tx.insert(alertGreTunnelSamples).values({
sampledAt,
targetLabel: r.targetLabel,
status: r.status,
}).run()
snapshot.greWritten += 1
}
for (const s of bgpRows) {
tx.insert(alertBgpPeerSamples).values({
sampledAt,
peerKey: bgpPeerAlertKey(s),
state: s.state,
}).run()
snapshot.bgpWritten += 1
}
})
sqliteDatabase
.prepare(`DELETE FROM alert_gre_tunnel_samples WHERE sampled_at < datetime('now', '-30 days')`)
.run()
sqliteDatabase
.prepare(`DELETE FROM alert_bgp_peer_samples WHERE sampled_at < datetime('now', '-30 days')`)
.run()
if (errors.length) snapshot.errors = errors
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
snapshot.fatalError = msg
} finally {
collecting = false
}
return snapshot
}
+60
View File
@@ -0,0 +1,60 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosGre {
".id"?: string
name?: string
"local-address"?: string
"remote-address"?: string
"allow-fast-path"?: string
"clamp-tcp-mss"?: string
mtu?: string
"keepalive"?: string
dscp?: string
running?: string
disabled?: string
comment?: string
}
export type GreTunnelLiveStatus = "up" | "down" | "degraded"
/** Как в UI `/alerts`: `${tunnelName} / ${serverName}`. */
export type GreTunnelLiveRow = {
targetLabel: string
status: GreTunnelLiveStatus
}
function mapGreRow(server: ServerRow, g: RosGre, idx: number): GreTunnelLiveRow {
const running = g.running === "true"
const disabled = g.disabled === "true"
const status: GreTunnelLiveStatus = disabled ? "down" : running ? "up" : "degraded"
const sn = String(server.name ?? "").trim() || String(server.host ?? "").trim() || String(server.id)
const tn = (String(g.name ?? "").trim() || String(g[".id"] ?? "").trim() || `gre-${idx + 1}`).trim()
const label = `${tn} / ${sn}`.trim()
return { targetLabel: label, status }
}
async function fetchGreForServer(server: ServerRow): Promise<GreTunnelLiveRow[]> {
const client = MikrotikClient.fromServer(server)
const greRaw = await client.get<RosGre[]>("/interface/gre")
return greRaw.map((g, idx) => mapGreRow(server, g, idx))
}
/** GRE с включённых серверов — тот же смысл, что `/api/filters/gre-tunnels` (без правил фильтров). */
export async function fetchGreTunnelLiveRows(): Promise<GreTunnelLiveRow[]> {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
return await fetchGreForServer(server)
} catch {
return [] as GreTunnelLiveRow[]
}
}),
)
return results.flat()
}
+107 -2
View File
@@ -1,6 +1,7 @@
import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
@@ -199,6 +200,76 @@ function rosDelete(
})
}
function rosPatch(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "PATCH",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
if (buf.trim() === "") {
resolve({})
return
}
try { resolve(JSON.parse(buf)) } catch { resolve({}) }
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.write(payload)
req.end()
})
}
/**
* RouterOS REST для одиночных меню (`/system/resource`, `/system/identity`) отдаёт JSON-массив из одного объекта `[{...}]`,
* а не сам объект. Без распаковки поля читаются с массива всегда undefined и в БД уходят нули при «успехе».
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
*/
function unwrapRestSingle<T extends object>(raw: unknown): T {
if (Array.isArray(raw)) {
const first = raw[0]
if (first && typeof first === "object") return first as T
throw new Error("RouterOS REST: пустой массив вместо записи")
}
if (raw && typeof raw === "object") return raw as T
throw new Error("RouterOS REST: неожиданное тело ответа")
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
@@ -228,14 +299,43 @@ export class MikrotikClient {
return rosDelete(this.params, path, timeoutMs)
}
async patch<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPatch(this.params, path, body, timeoutMs) as Promise<T>
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
return this.get<RosIdentity>("/system/identity")
const raw = await this.get<unknown>("/system/identity")
return unwrapRestSingle<RosIdentity>(raw)
}
async getResource(): Promise<RosResource> {
return this.get<RosResource>("/system/resource")
const raw = await this.get<unknown>("/system/resource")
if (Array.isArray(raw)) {
if (raw.length === 0) throw new Error("RouterOS REST: пустой массив вместо записи /system/resource")
if (raw.length === 1) return unwrapRestSingle<RosResource>(raw[0])
/** Несколько объектов: взять строку с total-memory (агрегат), а не дочернюю запись с «чужим» uptime. */
const objs = raw.filter((x): x is Record<string, unknown> => x != null && typeof x === "object" && !Array.isArray(x))
/** На части железа несколько строк; «первая с непустым total-memory» может парситься в 0 — берём запись с максимальным RAM. */
let best: Record<string, unknown> | undefined
let bestBytes = -1
for (const o of objs) {
const tm = parseRosDataSizeBytes(o["total-memory"])
const fm = parseRosDataSizeBytes(o["free-memory"])
const score = tm > 0 ? tm : fm
if (score > bestBytes) {
bestBytes = score
best = o
}
}
const main = objs.find((o) => {
const tm = o["total-memory"]
return tm != null && String(tm).trim() !== ""
})
return ((bestBytes > 0 ? best : undefined) ?? main ?? objs[0]) as unknown as RosResource
}
return unwrapRestSingle<RosResource>(raw)
}
async getInterfaces(): Promise<RosInterface[]> {
@@ -277,6 +377,11 @@ export class MikrotikClient {
return this.get<RosBfdSession[]>("/routing/bfd/session")
}
async setOspfInterfaceTemplateCost(templateId: string, cost: number): Promise<void> {
const safeId = templateId.startsWith("*") ? templateId : `*${templateId.replace(/^\*/, "")}`
await this.patch(`/routing/ospf/interface-template/${encodeURIComponent(safeId)}`, { cost: String(cost) })
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {
+7 -7
View File
@@ -4,6 +4,7 @@ import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
import type { SnapshotRead } from "../types/server.js"
import { MikrotikClient } from "./mikrotik.js"
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
// ── pollServer ─────────────────────────────────────────────────────────────────
@@ -45,10 +46,9 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
const latencyMs = performance.now() - t0
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
const cpuLoad = parseInt(resource["cpu-load"], 10)
const freeMem = parseInt(resource["free-memory"], 10)
const totalMem = parseInt(resource["total-memory"], 10)
const cpuLoad = parseRosCpuLoadPercent(resource["cpu-load"])
const freeMem = parseRosDataSizeBytes(resource["free-memory"])
const totalMem = parseRosDataSizeBytes(resource["total-memory"])
Object.assign(partialSnap, {
status: "online",
@@ -57,9 +57,9 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
rosVersion: resource["version"],
boardName: resource["board-name"],
uptime: resource["uptime"],
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
freeMemory: isNaN(freeMem) ? null : freeMem,
totalMemory: isNaN(totalMem) ? null : totalMem,
cpuLoad,
freeMemory: freeMem,
totalMemory: totalMem,
rawInterfaces: JSON.stringify(ifaces),
rawIpAddresses: JSON.stringify(addresses),
} satisfies Partial<SnapshotInsert>)
+106
View File
@@ -0,0 +1,106 @@
/**
* Разбор полей /system/resource из RouterOS REST: не всегда «сырые» байты в виде десятичной строки.
* На железе часто встречаются значения вида "224.0MiB", "94.2MiB"; parseInt от этого даёт мусор или 0.
*/
export function parseRosDataSizeBytes(raw: unknown): number {
if (raw == null) return 0
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(0, Math.floor(raw))
let s = String(raw).trim().replace(/\s+/g, "")
if (!s) return 0
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? Math.max(0, n) : 0
}
const m = s.match(/^(\d+(?:[.,]\d+)?)(KiB|MiB|GiB|TiB|Ki|Mi|Gi|Ti|KB|MB|GB|TB)$/i)
if (m) {
const v = Number.parseFloat(m[1].replace(",", "."))
if (!Number.isFinite(v) || v < 0) return 0
const u = m[2].toLowerCase()
const mult =
u === "ki" || u === "kib" || u === "kb" ? 1024
: u === "mi" || u === "mib" || u === "mb" ? 1024 ** 2
: u === "gi" || u === "gib" || u === "gb" ? 1024 ** 3
: u === "ti" || u === "tib" || u === "tb" ? 1024 ** 4
: 1
return Math.max(0, Math.floor(v * mult))
}
const fallback = Number.parseInt(s, 10)
return Number.isFinite(fallback) ? Math.max(0, fallback) : 0
}
/** cpu-load: "7%", "7", 7 */
export function parseRosCpuLoadPercent(raw: unknown): number {
if (raw == null) return 0
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.min(100, Math.max(0, Math.round(raw)))
}
const s = String(raw).trim().replace("%", "").replace(",", ".").trim()
if (!s) return 0
const n = Number.parseFloat(s)
if (!Number.isFinite(n)) return 0
return Math.min(100, Math.max(0, Math.round(n)))
}
/**
* `uptime` из /system/resource (REST): обычно "2d20h12m20s", "39m", "29s".
* Встречаются варианты: только секунды строкой, `d.hh:mm:ss`, `hh:mm:ss` без суффиксов.
* @see https://help.mikrotik.com/docs/display/ROS/Resource
*/
export function parseRosUptimeToSeconds(raw: unknown): number {
if (raw == null) return 0
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(0, Math.floor(raw))
}
const s0 = String(raw).trim().replace(/\u00a0/g, " ").trim()
if (!s0) return 0
// Только десятичные цифры — секунды (некоторые клиенты/прокси)
if (/^\d+$/.test(s0)) {
const n = Number.parseInt(s0, 10)
return Number.isFinite(n) ? Math.max(0, n) : 0
}
const s = s0.toLowerCase().replace(/\s+/g, "")
// 1d18:27:15
const dClock = s.match(/^(\d+)d(\d{1,3}):(\d{2}):(\d{2})$/i)
if (dClock) {
const d = Number.parseInt(dClock[1], 10)
const hh = Number.parseInt(dClock[2], 10)
const mm = Number.parseInt(dClock[3], 10)
const ss = Number.parseInt(dClock[4], 10)
if ([d, hh, mm, ss].every((x) => Number.isFinite(x) && x >= 0)) {
return d * 86400 + hh * 3600 + mm * 60 + ss
}
}
// 18:27:15 — только если нет суффиксов w/d/h/m/s (иначе это часть "2d20h12m20s")
if (!/[wdhms]/i.test(s0) && /^\d{1,7}:\d{2}:\d{2}$/.test(s)) {
const p = s.split(":")
if (p.length === 3) {
const hh = Number.parseInt(p[0], 10)
const mm = Number.parseInt(p[1], 10)
const sec = Number.parseInt(p[2], 10)
if ([hh, mm, sec].every((x) => Number.isFinite(x) && x >= 0)) {
return hh * 3600 + mm * 60 + sec
}
}
}
let total = 0
for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/gi)) {
const n = Number.parseInt(m[1], 10)
if (!Number.isFinite(n) || n < 0) continue
const u = m[2].toLowerCase()
switch (u) {
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
default: break
}
}
return Math.max(0, total)
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Реестр активных джоб планировщика (без импорта `scheduler.ts` нет цикла с `run-once`).
* Слот берётся до `await` в теле джобы так `alert_engine` может дождаться REST и др. до чтения БД.
*/
const runningSchedulerJobs = new Set<string>()
const ALERT_SNAPSHOT_SOURCE_JOB_KEYS = new Set<string>([
"traffic",
"servers_rest_ping",
"uptime_resources",
"uptime_ping",
"uptime_speed",
"gre_bgp",
])
/** true, если слот занят (джоба уже выполняется). */
export function tryBeginSchedulerJob(jobKey: string): boolean {
if (runningSchedulerJobs.has(jobKey)) return false
runningSchedulerJobs.add(jobKey)
return true
}
export function endSchedulerJob(jobKey: string): void {
runningSchedulerJobs.delete(jobKey)
}
export function isSchedulerJobRunning(jobKey: string): boolean {
return runningSchedulerJobs.has(jobKey)
}
/** Есть ли коллектор, который пишет таблицы снимка для `buildSignalSnapshot`. */
export function isAnyAlertSnapshotSourceJobRunning(): boolean {
for (const k of ALERT_SNAPSHOT_SOURCE_JOB_KEYS) {
if (runningSchedulerJobs.has(k)) return true
}
return false
}
+362
View File
@@ -0,0 +1,362 @@
/**
* In-process планировщик: отдельный интервал на job (`refreshScheduler`).
*
* **Приоритеты при конкуренции** (для дальнейшего per-server mutex; сейчас зафиксировано в дизайне):
* 1. `uptime_ping` выше (короткий критичный сигнал).
* 2. `traffic`, `uptime_resources`, `servers_rest_ping` средний (REST `/system/identity` по каталогу).
* 3. `uptime_speed` ниже (BW-test, тяжёлый); локи узлов `withBtestNodeLocks` в speed-сервисе.
*
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
* `gre_bgp` `alert_gre_tunnel_samples` / `alert_bgp_peer_samples`. После успешного завершения джоб
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
*
* **Кастомные job без произвольного кода (MVP):** пер-тайминг ping в `uptime_probes.interval_sec`;
* таблица `scheduler_job_instances` (kind + config JSON) при необходимости следующий этап.
*/
import { desc, eq, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { schedulerRuns } from "../db/schema.js"
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import {
collectServersRestPingOnce,
getServersApiPingSettings,
} from "./servers-rest-ping-collector.js"
import { collectTrafficOnce, getTrafficSettings } from "./traffic-collector.js"
import {
collectPingProbesOnce,
collectResourceSamplesOnce,
getSettings as getUptimeSettings,
} from "./uptime-collector.js"
import { runScheduledSpeedProbesOnce } from "./uptime-speed-service.js"
import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js"
import { runAlertEngineOnce } from "./alert-engine/run-once.js"
import {
clearAlertCollectorHooksTimer,
scheduleAlertEngineAfterDataCollectors,
wireAlertEngineRunner,
} from "./alert-collector-hooks.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
tryBeginSchedulerJob,
} from "./scheduler-running.js"
export const JOB_KEYS = [
"traffic",
"servers_rest_ping",
"uptime_resources",
"uptime_ping",
"uptime_speed",
"gre_bgp",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
const timers = new Map<string, ReturnType<typeof setInterval>>()
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
function newRunId(): string {
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function appendSchedulerRun(row: {
jobKey: string
startedAt: string
finishedAt: string
status: "ok" | "error"
error: string | null
durationMs: number
result?: SchedulerRunSnapshot | null
}) {
db.insert(schedulerRuns).values({
id: newRunId(),
jobKey: row.jobKey,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
status: row.status,
error: row.error,
durationMs: row.durationMs,
resultJson: row.result ? JSON.stringify(row.result) : null,
}).run()
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
db.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
}
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
const startedAt = Date.now()
const startedIso = new Date().toISOString()
let snapshot: SchedulerRunSnapshot | undefined
try {
switch (jobKey) {
case "traffic":
snapshot = await collectTrafficOnce()
break
case "uptime_resources":
snapshot = await collectResourceSamplesOnce()
break
case "uptime_ping":
snapshot = await collectPingProbesOnce()
break
case "uptime_speed":
snapshot = await runScheduledSpeedProbesOnce()
break
case "servers_rest_ping":
snapshot = await collectServersRestPingOnce()
break
case "gre_bgp":
snapshot = await collectGreBgpSnapshotOnce()
break
case "alert_engine": {
const r = await runAlertEngineOnce()
snapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "alert_engine",
sampledAt: r.sampledAt,
rulesChecked: r.rulesChecked,
standaloneFires: r.standaloneFires,
groupFires: r.groupFires,
skippedNoTelegram: r.skippedNoTelegram,
...(r.ruleDiag.length ? { ruleDiag: r.ruleDiag } : {}),
...(r.errors.length ? { errors: r.errors } : {}),
}
break
}
default:
throw new Error(`Unknown job: ${jobKey}`)
}
const finishedIso = new Date().toISOString()
appendSchedulerRun({
jobKey,
startedAt: startedIso,
finishedAt: finishedIso,
status: "ok",
error: null,
durationMs: Date.now() - startedAt,
result: snapshot ?? null,
})
if (
jobKey === "traffic" ||
jobKey === "servers_rest_ping" ||
jobKey === "uptime_resources" ||
jobKey === "uptime_ping" ||
jobKey === "uptime_speed" ||
jobKey === "gre_bgp"
) {
scheduleAlertEngineAfterDataCollectors()
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
const finishedIso = new Date().toISOString()
appendSchedulerRun({
jobKey,
startedAt: startedIso,
finishedAt: finishedIso,
status: "error",
error: msg,
durationMs: Date.now() - startedAt,
result: snapshot ?? null,
})
throw e
}
}
/** Фоновый тик: пропуск, если предыдущий прогон ещё идёт. */
export async function executeSchedulerJob(jobKey: SchedulerJobKey): Promise<void> {
if (!tryBeginSchedulerJob(jobKey)) return
try {
await runSchedulerJobBody(jobKey)
} catch {
/* залогировано в runSchedulerJobBody */
} finally {
endSchedulerJob(jobKey)
}
}
/** Ручной запуск: 409, если job уже выполняется. */
export async function runSchedulerJobNow(jobKey: string): Promise<void> {
if (!JOB_KEYS.includes(jobKey as SchedulerJobKey)) {
throw new Error(`Unknown job key: ${jobKey}`)
}
if (!tryBeginSchedulerJob(jobKey)) {
const err = new Error("Job already running")
;(err as Error & { statusCode?: number }).statusCode = 409
throw err
}
try {
await runSchedulerJobBody(jobKey as SchedulerJobKey)
} finally {
endSchedulerJob(jobKey)
}
}
function clearAllTimers() {
clearAlertCollectorHooksTimer()
for (const t of timers.values()) clearInterval(t)
timers.clear()
}
/** Пересоздать интервалы после смены настроек. */
export function refreshScheduler(): void {
clearAllTimers()
const traffic = getTrafficSettings()
if (traffic.enabled) {
const ms = Math.max(5_000, traffic.intervalSec * 1000)
void executeSchedulerJob("traffic").catch(() => {})
timers.set(
"traffic",
setInterval(() => {
void executeSchedulerJob("traffic").catch(() => {})
}, ms),
)
}
const apiPing = getServersApiPingSettings()
if (apiPing.enabled) {
const apiMs = Math.max(10_000, apiPing.intervalSec * 1000)
void executeSchedulerJob("servers_rest_ping").catch(() => {})
timers.set(
"servers_rest_ping",
setInterval(() => {
void executeSchedulerJob("servers_rest_ping").catch(() => {})
}, apiMs),
)
}
const uptime = getUptimeSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
const spdOn = uptime.speedEnabled ?? uptime.enabled
if (resOn) {
const resMs = Math.max(5_000, uptime.intervalSec * 1000)
void executeSchedulerJob("uptime_resources").catch(() => {})
timers.set(
"uptime_resources",
setInterval(() => {
void executeSchedulerJob("uptime_resources").catch(() => {})
}, resMs),
)
}
if (pingOn) {
const pingMs = Math.max(1_000, uptime.probeIntervalSec * 1000)
void executeSchedulerJob("uptime_ping").catch(() => {})
timers.set(
"uptime_ping",
setInterval(() => {
void executeSchedulerJob("uptime_ping").catch(() => {})
}, pingMs),
)
}
if (spdOn) {
const spdMs = Math.max(10_000, uptime.speedIntervalSec * 1000)
void executeSchedulerJob("uptime_speed").catch(() => {})
timers.set(
"uptime_speed",
setInterval(() => {
void executeSchedulerJob("uptime_speed").catch(() => {})
}, spdMs),
)
}
const greBgpMs = 30_000
void executeSchedulerJob("gre_bgp").catch(() => {})
timers.set(
"gre_bgp",
setInterval(() => {
void executeSchedulerJob("gre_bgp").catch(() => {})
}, greBgpMs),
)
const alertMs = 20_000
void executeSchedulerJob("alert_engine").catch(() => {})
timers.set(
"alert_engine",
setInterval(() => {
void executeSchedulerJob("alert_engine").catch(() => {})
}, alertMs),
)
}
export function stopScheduler(): void {
clearAllTimers()
}
export function isJobRunning(jobKey: string): boolean {
return isSchedulerJobRunning(jobKey)
}
function lastRunForJob(jobKey: string) {
return db.select().from(schedulerRuns)
.where(eq(schedulerRuns.jobKey, jobKey))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(1)
.all()[0]
}
export function getSchedulerStatus() {
const traffic = getTrafficSettings()
const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
const spdOn = uptime.speedEnabled ?? uptime.enabled
const jobMeta: Record<SchedulerJobKey, { enabled: boolean; intervalSec: number }> = {
traffic: { enabled: traffic.enabled, intervalSec: traffic.intervalSec },
servers_rest_ping: { enabled: apiPing.enabled, intervalSec: apiPing.intervalSec },
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec },
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
gre_bgp: { enabled: true, intervalSec: 30 },
alert_engine: { enabled: true, intervalSec: 20 },
}
return {
jobs: JOB_KEYS.map((jobKey) => {
const last = lastRunForJob(jobKey)
const m = jobMeta[jobKey]
return {
jobKey,
enabled: m.enabled,
intervalSec: m.intervalSec,
running: isSchedulerJobRunning(jobKey),
lastFinishedAt: last?.finishedAt ?? null,
lastStatus: last?.status ?? null,
lastDurationMs: last?.durationMs ?? null,
lastError: last?.error ?? null,
}
}),
}
}
export function listSchedulerRuns(opts: { jobKey?: string; limit: number; offset: number }) {
const limit = Math.min(200, Math.max(1, opts.limit))
const offset = Math.max(0, opts.offset)
if (opts.jobKey) {
return {
runs: db.select().from(schedulerRuns)
.where(eq(schedulerRuns.jobKey, opts.jobKey))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(limit)
.offset(offset)
.all(),
}
}
return {
runs: db.select().from(schedulerRuns)
.orderBy(desc(schedulerRuns.finishedAt))
.limit(limit)
.offset(offset)
.all(),
}
}
wireAlertEngineRunner(() => executeSchedulerJob("alert_engine"))
@@ -0,0 +1,202 @@
import { eq, or } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import { servers, serversApiPingSettings, serversRestPingSamples } from "../db/schema.js"
import type { ServersRestPingRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js"
let collecting = false
function getSettingsRow() {
const row = db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(serversApiPingSettings).values({
id: 1,
enabled: false,
intervalSec: 120,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0]
}
/** Как uptime: включённые в каталоге или Home Router (часто «выкл.» при доступе только из LAN). */
function serversForRestProbe() {
return db
.select()
.from(servers)
.where(or(eq(servers.enabled, true), eq(servers.type, "home-router")))
.all()
}
export function getServersApiPingSettings() {
return getSettingsRow()
}
export function updateServersApiPingSettings(patch: { enabled?: boolean; intervalSec?: number }) {
const prev = getSettingsRow()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
updatedAt: new Date().toISOString(),
}
db.update(serversApiPingSettings).set(next).where(eq(serversApiPingSettings.id, 1)).run()
return getSettingsRow()
}
export function getServersRestPingCollectorState(): { running: boolean } {
return { running: collecting }
}
function loadLastRestSampleByServer(): Map<number, { ok: boolean }> {
const rows = sqliteDatabase
.prepare(
`SELECT s.server_id AS serverId, s.ok
FROM servers_rest_ping_samples s
INNER JOIN (
SELECT server_id, MAX(id) AS mid FROM servers_rest_ping_samples GROUP BY server_id
) t ON t.mid = s.id`,
)
.all() as { serverId: number; ok: number }[]
const m = new Map<number, { ok: boolean }>()
for (const r of rows) m.set(r.serverId, { ok: Boolean(r.ok) })
return m
}
/** Последний сэмпл ресурсов — для согласования «восстановился» с REST без второго тика uptime. */
function loadLatestResourceDownServerIds(): Set<number> {
const rows = sqliteDatabase
.prepare(
`WITH r AS (
SELECT urs.server_id AS serverId, urs.status,
ROW_NUMBER() OVER (PARTITION BY urs.server_id ORDER BY urs.id DESC) AS rn
FROM uptime_resource_samples urs
)
SELECT serverId FROM r WHERE rn = 1 AND status IN ('offline', 'degraded')`,
)
.all() as { serverId: number }[]
return new Set(rows.map((x) => x.serverId))
}
/**
* Лёгкая проверка: GET `/system/identity` (REST) на узел, замер RTT.
* Пишет строки в `servers_rest_ping_samples` для алерт-движка (см. `buildSignalSnapshot`).
*/
export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "servers_rest_ping",
sampledAt,
skipped: true,
servers: [],
}
}
collecting = true
const startedAt = Date.now()
const snapshot: ServersRestPingRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "servers_rest_ping",
sampledAt,
servers: [],
}
try {
const targets = serversForRestProbe()
const rows = await Promise.all(
targets.map(async (srv) => {
const name = srv.name || srv.host
const t0 = performance.now()
try {
const client = MikrotikClient.fromServer(srv)
await client.getIdentity()
const latencyMs = Math.round(performance.now() - t0)
return {
serverId: srv.id,
name,
host: srv.host,
ok: true as const,
latencyMs,
}
} catch (err) {
return {
serverId: srv.id,
name,
host: srv.host,
ok: false as const,
latencyMs: null as number | null,
error: err instanceof Error ? err.message : String(err),
}
}
}),
)
snapshot.servers = rows.sort((a, b) => a.serverId - b.serverId)
const lastByServer = loadLastRestSampleByServer()
const resourceDown = loadLatestResourceDownServerIds()
db.transaction((tx) => {
for (const row of snapshot.servers) {
const sid = row.serverId
const last = lastByServer.get(sid)
const errText = row.ok ? null : (row.error ?? null)
const insertOne = (ok: boolean, latencyMs: number | null, error: string | null) => {
tx.insert(serversRestPingSamples).values({
serverId: sid,
sampledAt,
ok,
latencyMs,
error,
}).run()
}
const lat = row.latencyMs ?? null
if (row.ok) {
const downRes = resourceDown.has(sid)
if (last === undefined && downRes) {
insertOne(false, null, null)
}
insertOne(true, lat, null)
if (last?.ok === false || (last === undefined && downRes)) {
insertOne(true, lat, null)
}
} else {
insertOne(false, null, errText)
}
}
})
sqliteDatabase
.prepare(`DELETE FROM servers_rest_ping_samples WHERE sampled_at < datetime('now', '-30 days')`)
.run()
db.update(serversApiPingSettings)
.set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - startedAt,
lastError: "",
updatedAt: sampledAt,
})
.where(eq(serversApiPingSettings.id, 1))
.run()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
snapshot.fatalError = msg
db.update(serversApiPingSettings)
.set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - startedAt,
lastError: msg,
updatedAt: sampledAt,
})
.where(eq(serversApiPingSettings.id, 1))
.run()
} finally {
collecting = false
}
return snapshot
}
+75 -50
View File
@@ -1,6 +1,8 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js"
interface RosIfaceTraffic {
@@ -19,13 +21,7 @@ export interface TrafficCollectorState {
lastError: string | null
}
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
const state: TrafficCollectorState = {
running: false,
lastRunAt: null,
lastError: null,
}
function toNum(raw: unknown): number {
const n = Number.parseFloat(String(raw ?? "0"))
@@ -54,36 +50,74 @@ function cleanupOldSamples(retentionDays: number) {
.run()
}
export async function collectTrafficOnce(): Promise<void> {
if (collecting) return
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "traffic",
sampledAt,
skipped: true,
servers: [],
}
}
collecting = true
const startedAt = Date.now()
const now = new Date().toISOString()
const now = sampledAt
const settings = getSettingsRow()
const snapshot: TrafficRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "traffic",
sampledAt: now,
servers: [],
}
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
for (const srv of enabledServers) {
const name = srv.name || srv.host
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
if (ifaces.length === 0) continue
db.insert(trafficSamples).values(
ifaces.map((i) => ({
serverId: srv.id,
interfaceName: i.name ?? "unknown",
sampledAt: now,
rxBytes: toNum(i["rx-byte"]),
txBytes: toNum(i["tx-byte"]),
rxBps: toNum(i["rx-bits-per-second"]),
txBps: toNum(i["tx-bits-per-second"]),
running: (i.running ?? "false") === "true",
disabled: (i.disabled ?? "false") === "true",
})),
).run()
} catch {
// Continue collecting from remaining servers
let sumRx = 0
let sumTx = 0
for (const i of ifaces) {
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
}
if (ifaces.length > 0) {
db.insert(trafficSamples).values(
ifaces.map((i) => ({
serverId: srv.id,
interfaceName: i.name ?? "unknown",
sampledAt: now,
rxBytes: toNum(i["rx-byte"]),
txBytes: toNum(i["tx-byte"]),
rxBps: toNum(i["rx-bits-per-second"]),
txBps: toNum(i["tx-bits-per-second"]),
running: (i.running ?? "false") === "true",
disabled: (i.disabled ?? "false") === "true",
})),
).run()
}
snapshot.servers.push({
serverId: srv.id,
name,
host: srv.host,
ok: true,
interfaces: ifaces.length,
sumRxMbps: Math.round(sumRx),
sumTxMbps: Math.round(sumTx),
})
} catch (err) {
snapshot.servers.push({
serverId: srv.id,
name,
host: srv.host,
ok: false,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -94,45 +128,38 @@ export async function collectTrafficOnce(): Promise<void> {
lastError: "",
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = null
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
snapshot.fatalError = msg
db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: msg,
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = msg
} finally {
collecting = false
}
return snapshot
}
export function stopTrafficCollector() {
if (timer) {
clearInterval(timer)
timer = null
}
state.running = false
}
/** Таймеры трафика ведёт планировщик. */
export function stopTrafficCollector() {}
export function restartTrafficCollector() {
stopTrafficCollector()
const settings = getSettingsRow()
if (!settings.enabled) return
const intervalMs = Math.max(5, settings.intervalSec) * 1000
timer = setInterval(() => {
void collectTrafficOnce()
}, intervalMs)
state.running = true
}
export function restartTrafficCollector() {}
export function getTrafficCollectorState(): TrafficCollectorState {
return { ...state }
const s = getSettingsRow()
return {
running: Boolean(s.enabled),
lastRunAt: s.lastCollectedAt ?? null,
lastError: s.lastError || null,
}
}
/** Идёт активный прогон `collectTrafficOnce` (запись в SQLite ещё не завершена). */
export function isTrafficCollecting(): boolean {
return collecting
}
export function getTrafficSettings() {
@@ -152,7 +179,6 @@ export function updateTrafficSettings(patch: {
updatedAt: new Date().toISOString(),
}
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
restartTrafficCollector()
return getSettingsRow()
}
@@ -166,4 +192,3 @@ export function readServerSamplesInRange(serverId: number, sinceIso: string) {
.orderBy(asc(trafficSamples.sampledAt))
.all()
}
+305 -75
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { and, asc, eq, gte, inArray, lt, max, or } from "drizzle-orm"
import { db } from "../db/index.js"
import {
servers,
@@ -7,31 +7,18 @@ import {
uptimeResourceSamples,
uptimeSettings,
} from "../db/schema.js"
import type { PingRunSnapshot, ResourcesRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { PING_PROBE_WARN_RTT_MS } from "../constants/ping-probe.js"
import { MikrotikClient } from "./mikrotik.js"
import { parseRosCpuLoadPercent, parseRosDataSizeBytes, parseRosUptimeToSeconds } from "./ros-metric-parse.js"
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
let collectingResources = false
let collectingPing = false
function parseDurationToSeconds(raw: string | undefined): number {
if (!raw) return 0
let total = 0
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = Number.parseInt(m[1], 10)
if (!Number.isFinite(n)) continue
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
function toNum(raw: unknown): number {
const n = Number.parseInt(String(raw ?? "0"), 10)
return Number.isFinite(n) ? Math.max(0, n) : 0
/** Параллельный прогон сборщиков uptime — чтобы `alert_engine` не читал БД до коммита сэмплов. */
export function getUptimeCollectorsState(): { resources: boolean; ping: boolean } {
return { resources: collectingResources, ping: collectingPing }
}
function parsePingTimeMs(raw: string | undefined): number | null {
@@ -44,7 +31,6 @@ function parsePingTimeMs(raw: string | undefined): number | null {
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
if (sec) return Number.parseFloat(sec[1]) * 1000
// HH:MM:SS(.sss) from some RouterOS outputs
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
if (clock) {
const h = Number.parseFloat(clock[1])
@@ -57,16 +43,19 @@ function parsePingTimeMs(raw: string | undefined): number | null {
return Number.isFinite(n) ? n : null
}
function getSettings() {
export function getSettings() {
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(uptimeSettings).values({
id: 1,
enabled: true,
intervalSec: 15,
resourcesEnabled: true,
pingEnabled: true,
speedEnabled: true,
intervalSec: 300,
probeIntervalSec: 15,
speedIntervalSec: 60,
speedIntervalSec: 3600,
retentionDays: 14,
createdAt: now,
updatedAt: now,
@@ -105,38 +94,101 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent?
typeof lossSource === "number" && Number.isFinite(lossSource)
? Math.max(0, Math.min(100, lossSource))
: 100
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
const status: "up" | "warn" | "down" =
loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > PING_PROBE_WARN_RTT_MS ? "warn" : "up")
return { avgRtt, loss, status }
}
export async function collectUptimeOnce(): Promise<void> {
if (collecting) return
collecting = true
/** Узлы с REST-опросом для uptime: включённые в каталоге или любой Home Router (см. /servers — HR часто «выкл.» при доступе только из LAN). */
function serversForUptimeRosPoll() {
return db
.select()
.from(servers)
.where(or(eq(servers.enabled, true), eq(servers.type, "home-router")))
.all()
}
function lastProbeSampleTimes(): Map<string, string> {
const rows = db.select({
probeId: uptimeProbeSamples.probeId,
lastAt: max(uptimeProbeSamples.sampledAt),
}).from(uptimeProbeSamples).groupBy(uptimeProbeSamples.probeId).all()
const m = new Map<string, string>()
for (const r of rows) {
if (r.lastAt) m.set(r.probeId, String(r.lastAt))
}
return m
}
/** Сбор метрик ресурсов (CPU/RAM/…) по всем включённым серверам. */
export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collectingResources) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_resources",
sampledAt,
skipped: true,
servers: [],
}
}
collectingResources = true
const started = Date.now()
const now = new Date().toISOString()
const now = sampledAt
const settings = getSettings()
const snapshot: ResourcesRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_resources",
sampledAt: now,
servers: [],
}
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const resourceTargets = serversForUptimeRosPoll()
for (const s of enabledServers) {
for (const s of resourceTargets) {
const name = s.name || s.host
try {
const client = MikrotikClient.fromServer(s)
const resource = await client.getResource()
const cpuLoad = parseRosCpuLoadPercent(resource["cpu-load"])
const freeMem = parseRosDataSizeBytes(resource["free-memory"])
const totalMem = parseRosDataSizeBytes(resource["total-memory"])
const freeHdd = parseRosDataSizeBytes(resource["free-hdd-space"])
const totalHdd = parseRosDataSizeBytes(resource["total-hdd-space"])
const uptimeSeconds = parseRosUptimeToSeconds(resource["uptime"])
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "online",
cpuLoad: toNum(resource["cpu-load"]),
freeMemory: toNum(resource["free-memory"]),
totalMemory: toNum(resource["total-memory"]),
freeHddSpace: toNum(resource["free-hdd-space"]),
totalHddSpace: toNum(resource["total-hdd-space"]),
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
cpuLoad,
freeMemory: freeMem,
totalMemory: totalMem,
freeHddSpace: freeHdd,
totalHddSpace: totalHdd,
uptimeSeconds,
boardName: String(resource["board-name"] ?? ""),
rosVersion: String(resource["version"] ?? ""),
}).run()
} catch {
const memUsedMb = totalMem > 0 ? Math.round((totalMem - freeMem) / (1024 * 1024)) : 0
const memTotalMb = totalMem > 0 ? Math.round(totalMem / (1024 * 1024)) : 0
snapshot.servers.push({
serverId: s.id,
name,
host: s.host,
status: "online",
cpuLoadPct: cpuLoad,
memUsedMb,
memTotalMb,
memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 100) : undefined,
diskFreeMb: freeHdd > 0 ? Math.round(freeHdd / (1024 * 1024)) : 0,
diskTotalMb: totalHdd > 0 ? Math.round(totalHdd / (1024 * 1024)) : 0,
uptimeSeconds,
boardName: String(resource["board-name"] ?? "") || undefined,
rosVersion: String(resource["version"] ?? "") || undefined,
})
} catch (err) {
console.warn(`[uptime-collector] resource serverId=${s.id} (${s.host}):`, err instanceof Error ? err.message : err)
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
@@ -150,12 +202,180 @@ export async function collectUptimeOnce(): Promise<void> {
boardName: "",
rosVersion: "",
}).run()
snapshot.servers.push({
serverId: s.id,
name,
host: s.host,
status: "offline",
error: err instanceof Error ? err.message : String(err),
})
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} catch (e) {
snapshot.fatalError = e instanceof Error ? e.message : String(e)
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: e instanceof Error ? e.message : String(e),
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} finally {
collectingResources = false
}
return snapshot
}
/** Ping-пробы: учитывается interval_sec у пробы или глобальный probe_interval_sec. */
export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
const sampledAt = new Date().toISOString()
if (collectingPing) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_ping",
sampledAt,
skipped: true,
probes: [],
}
}
collectingPing = true
const started = Date.now()
const now = sampledAt
const settings = getSettings()
const defaultProbeSec = Math.max(5, settings.probeIntervalSec ?? settings.intervalSec)
const lastByProbe = lastProbeSampleTimes()
const nowMs = Date.now()
const snapshot: PingRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_ping",
sampledAt: now,
probes: [],
skippedByInterval: 0,
}
try {
const rosPollServers = serversForUptimeRosPoll()
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
for (const p of probes) {
const src = enabledServers.find((s) => s.id === p.srcServerId)
const effectiveSec = (p.intervalSec != null && p.intervalSec > 0) ? p.intervalSec : defaultProbeSec
const lastIso = lastByProbe.get(p.id)
if (lastIso) {
const elapsed = nowMs - Date.parse(lastIso)
if (elapsed < effectiveSec * 1000) {
snapshot.skippedByInterval = (snapshot.skippedByInterval ?? 0) + 1
continue
}
}
const src = rosPollServers.find((s) => s.id === p.srcServerId)
if (!src) continue
const srcServerName = src.name || src.host
try {
const client = MikrotikClient.fromServer(src)
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
const parsed = parsePing(results)
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
}).run()
snapshot.probes.push({
probeId: p.id,
name: p.name,
target: p.target,
srcServerId: p.srcServerId,
srcServerName,
srcInterface: p.srcInterface || "",
ok: true,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
})
} catch (err) {
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: null,
lossPct: 100,
status: "down",
}).run()
snapshot.probes.push({
probeId: p.id,
name: p.name,
target: p.target,
srcServerId: p.srcServerId,
srcServerName,
srcInterface: p.srcInterface || "",
ok: false,
rttMs: null,
lossPct: 100,
status: "down",
error: err instanceof Error ? err.message : String(err),
})
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} catch (e) {
snapshot.fatalError = e instanceof Error ? e.message : String(e)
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: e instanceof Error ? e.message : String(e),
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} finally {
collectingPing = false
}
return snapshot
}
const MAX_PROBE_IDS_PER_GROUP_COLLECT = 64
/**
* Немедленный ping по указанным пробам (без учёта interval_sec) запись в uptime_probe_samples.
* Используется кнопкой «Обновить» у группы «сервер + назначение» на вкладке проб.
*/
export async function collectPingForProbeIds(probeIds: string[]): Promise<{ polled: number }> {
const uniqueIds = [...new Set(probeIds.filter((id) => typeof id === "string" && id.trim() !== ""))].slice(
0,
MAX_PROBE_IDS_PER_GROUP_COLLECT,
)
if (uniqueIds.length === 0) return { polled: 0 }
if (collectingPing) {
const e = new Error("Сбор ping уже выполняется, подождите")
;(e as Error & { statusCode?: number }).statusCode = 409
throw e
}
collectingPing = true
const now = new Date().toISOString()
const settings = getSettings()
let polled = 0
try {
const rosPollServers = serversForUptimeRosPoll()
const rows = db.select().from(uptimeProbes).where(inArray(uptimeProbes.id, uniqueIds)).all()
for (const p of rows) {
const src = rosPollServers.find((s) => s.id === p.srcServerId)
if (!src) continue
try {
const client = MikrotikClient.fromServer(src)
@@ -177,64 +397,74 @@ export async function collectUptimeOnce(): Promise<void> {
status: "down",
}).run()
}
polled += 1
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} catch (e) {
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: e instanceof Error ? e.message : String(e),
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
return { polled }
} finally {
collecting = false
collectingPing = false
}
}
export function restartUptimeCollector() {
const s = getSettings()
if (timer) clearInterval(timer)
timer = null
if (!s.enabled) return
const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
/** Совместимость: один проход ресурсы + ping (для ручного вызова без планировщика). */
export async function collectUptimeOnce(): Promise<void> {
await collectResourceSamplesOnce()
await collectPingProbesOnce()
}
export function stopUptimeCollector() {
if (timer) clearInterval(timer)
timer = null
}
/** no-op: таймеры управляются планировщиком */
export function restartUptimeCollector() {}
export function stopUptimeCollector() {}
export function readUptimeSettings() {
return getSettings()
}
export function updateUptimeSettings(patch: {
/** Устаревший: если передан без per-job флагов — выставляет все три job-флага. */
enabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
/** Интервал сбора метрик ресурсов (сек). */
intervalSec?: number
/** Интервал тика ping и значение по умолчанию для проб без своего interval_sec. */
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const nextProbeInterval = patch.probeIntervalSec ?? patch.intervalSec ?? prev.probeIntervalSec ?? prev.intervalSec
const anyJobFlag =
patch.resourcesEnabled !== undefined
|| patch.pingEnabled !== undefined
|| patch.speedEnabled !== undefined
let resourcesEnabled = patch.resourcesEnabled ?? prev.resourcesEnabled ?? prev.enabled
let pingEnabled = patch.pingEnabled ?? prev.pingEnabled ?? prev.enabled
let speedEnabled = patch.speedEnabled ?? prev.speedEnabled ?? prev.enabled
if (patch.enabled !== undefined && !anyJobFlag) {
resourcesEnabled = patch.enabled
pingEnabled = patch.enabled
speedEnabled = patch.enabled
}
const masterEnabled = Boolean(resourcesEnabled || pingEnabled || speedEnabled)
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: nextProbeInterval,
probeIntervalSec: nextProbeInterval,
enabled: masterEnabled,
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
probeIntervalSec: patch.probeIntervalSec ?? prev.probeIntervalSec,
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
restartUptimeCollector()
return readUptimeSettings()
}
@@ -260,20 +490,20 @@ export function replaceProbes(rows: Array<{
probeFilter: string
enabled: boolean
showOnDashboard?: boolean
intervalSec?: number
}>) {
const now = new Date().toISOString()
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
const nextIds = new Set(rows.map((r) => r.id))
// Remove only probes that user actually deleted (their samples are removed by FK cascade).
for (const p of existing) {
if (nextIds.has(p.id)) continue
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
}
// Upsert rows in-place to preserve sample history for unchanged probe ids.
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i]
const intervalSec = r.intervalSec != null && r.intervalSec > 0 ? Math.max(5, Math.floor(r.intervalSec)) : 0
const patch = {
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
@@ -282,6 +512,7 @@ export function replaceProbes(rows: Array<{
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
showOnDashboard: r.showOnDashboard ?? false,
intervalSec,
sortOrder: i,
updatedAt: now,
}
@@ -311,4 +542,3 @@ export function readResourceSamplesSince(sinceIso: string, serverId: number) {
.orderBy(asc(uptimeResourceSamples.sampledAt))
.all()
}
@@ -0,0 +1,350 @@
import { asc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
import type { SpeedRunSnapshot, SpeedScheduledRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import type { RosIpAddress } from "../types/server.js"
import { withBtestNodeLocks } from "./btest-node-locks.js"
import { MikrotikClient } from "./mikrotik.js"
import { parsePing } from "./uptime-collector.js"
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 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
}
const n = Number.parseFloat(txt.replace(/[^\d.]/g, ""))
if (!Number.isFinite(n)) return 0
return n > 10000 ? n / 1_000_000 : n
}
export interface SpeedTestRunParams {
runId?: string
probeId?: string
srcServerId: number
dstServerId: number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
}
export interface SpeedTestRunResult {
runId: string
srcInterface: string | null
dstInterface: string | null
address: string
srcAddress: string
dstAddress: string
srcInterfaceAddress: string | null
dstInterfaceAddress: string | null
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
txSeries: number[]
rxSeries: number[]
raw: Array<Record<string, string>>
afterBtPing: {
rttMs: number | null
lossPct: number | null
error: string | null
}
}
function humanizeSpeedError(raw: string): string {
return /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
}
/** Полный цикл bandwidth-test + after-ping + запись в БД. Вызывать внутри withBtestNodeLocks при необходимости — здесь lock уже снаружи. */
export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise<SpeedTestRunResult> {
const srcId = params.srcServerId
const dstId = params.dstServerId
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) throw new Error("Server not found")
const protocol = params.protocol === "udp" ? "udp" : "tcp"
const direction = params.direction === "transmit" || params.direction === "receive" ? params.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10)
const srcInterface = String(params.srcInterface ?? "").trim()
const dstInterface = String(params.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) {
throw new 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: SpeedTestRunResult["afterBtPing"] = { 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(params.probeId ?? "").trim()
const runId = String(params.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 {
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,
}
}
export async function runBandwidthSpeedTest(params: SpeedTestRunParams): Promise<SpeedTestRunResult> {
const srcId = params.srcServerId
const dstId = params.dstServerId
if (srcId === dstId) throw new Error("Source and destination must be different")
return withBtestNodeLocks([srcId, dstId], () => executeBandwidthSpeedTestInner(params))
}
export function recordSpeedTestFailure(params: {
probeId?: string
runId?: string
srcServerId: number
dstServerId: number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
message: string
}) {
const probeId = String(params.probeId ?? "").trim()
const message = humanizeSpeedError(params.message)
if (probeId) {
db.update(uptimeSpeedProbes).set({
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
updatedAt: new Date().toISOString(),
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}
db.insert(uptimeSpeedTestRuns).values({
id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
probeId: probeId || null,
srcServerId: params.srcServerId,
dstServerId: params.dstServerId,
srcInterface: String(params.srcInterface ?? "").trim(),
dstInterface: String(params.dstInterface ?? "").trim(),
srcAddress: null,
dstAddress: null,
srcInterfaceAddress: null,
dstInterfaceAddress: null,
protocol: params.protocol === "udp" ? "udp" : "tcp",
direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both",
durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10),
txAvgMbps: null,
rxAvgMbps: null,
pingRttMs: null,
pingLossPct: null,
pingError: null,
status: "error",
error: message,
createdAt: new Date().toISOString(),
}).run()
}
function serverDisplayName(id: number): string {
const s = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
return s ? (s.name || s.host) : `#${id}`
}
/** Фоновый прогон всех включённых speed-проб (по очереди). */
export async function runScheduledSpeedProbesOnce(): Promise<SpeedScheduledRunSnapshot> {
const sampledAt = new Date().toISOString()
const runs: SpeedRunSnapshot[] = []
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
const enabled = rows.filter((r) => r.enabled !== false && r.srcServerId !== r.dstServerId)
const protocol = (p: (typeof enabled)[number]) => (p.protocol === "udp" ? "udp" : "tcp")
const direction = (p: (typeof enabled)[number]) =>
p.direction === "transmit" || p.direction === "receive" ? p.direction : "both"
const durationSec = (p: (typeof enabled)[number]) => Math.max(3, p.durationSec || 10)
for (const p of enabled) {
try {
const r = await runBandwidthSpeedTest({
probeId: p.id,
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface || undefined,
dstInterface: p.dstInterface || undefined,
protocol: protocol(p) as "tcp" | "udp",
direction: direction(p) as "transmit" | "receive" | "both",
durationSec: durationSec(p),
})
runs.push({
probeId: p.id,
srcServerId: p.srcServerId,
srcServerName: serverDisplayName(p.srcServerId),
dstServerId: p.dstServerId,
dstServerName: serverDisplayName(p.dstServerId),
srcInterface: r.srcInterface ?? "",
dstInterface: r.dstInterface ?? "",
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
ok: true,
txAvgMbps: r.txAvgMbps,
rxAvgMbps: r.rxAvgMbps,
pingRttMs: r.afterBtPing.rttMs,
pingLossPct: r.afterBtPing.lossPct,
pingError: r.afterBtPing.error,
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
recordSpeedTestFailure({
probeId: p.id,
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
srcInterface: p.srcInterface,
dstInterface: p.dstInterface,
protocol: protocol(p),
direction: direction(p),
durationSec: p.durationSec,
message: msg,
})
runs.push({
probeId: p.id,
srcServerId: p.srcServerId,
srcServerName: serverDisplayName(p.srcServerId),
dstServerId: p.dstServerId,
dstServerName: serverDisplayName(p.dstServerId),
srcInterface: p.srcInterface || "",
dstInterface: p.dstInterface || "",
protocol: protocol(p),
direction: direction(p),
durationSec: durationSec(p),
ok: false,
error: msg,
})
}
}
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_speed",
sampledAt,
runs,
}
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Снимок результатов одного прогона планировщика (JSON в `scheduler_runs.result_json`).
* Версия `v` для совместимости при расширении схемы.
*/
export const SCHEDULER_RUN_SNAPSHOT_VERSION = 1 as const
export interface TrafficServerSnapshot {
serverId: number
name: string
host: string
ok: boolean
error?: string
/** Сколько интерфейсов записано в сэмплы */
interfaces?: number
/** Сумма RX по интерфейсам, Мбит/с (округл.) */
sumRxMbps?: number
/** Сумма TX по интерфейсам, Мбит/с (округл.) */
sumTxMbps?: number
}
export interface TrafficRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "traffic"
sampledAt: string
/** Прогон пропущен — предыдущий сбор ещё выполняется */
skipped?: boolean
/** Критическая ошибка до/вне цикла по серверам */
fatalError?: string
servers: TrafficServerSnapshot[]
}
export interface ResourceServerSnapshot {
serverId: number
name: string
host: string
status: "online" | "offline"
error?: string
cpuLoadPct?: number
memUsedMb?: number
memTotalMb?: number
memUsedPct?: number
diskFreeMb?: number
diskTotalMb?: number
uptimeSeconds?: number
boardName?: string
rosVersion?: string
}
export interface ResourcesRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "uptime_resources"
sampledAt: string
skipped?: boolean
fatalError?: string
servers: ResourceServerSnapshot[]
}
export interface PingProbeSnapshot {
probeId: string
name: string
target: string
srcServerId: number
srcServerName: string
srcInterface: string
ok: boolean
rttMs: number | null
lossPct: number
status: "up" | "warn" | "down"
error?: string
}
export interface PingRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "uptime_ping"
sampledAt: string
skipped?: boolean
fatalError?: string
/** Пробы, для которых в этом тике записан сэмпл (не пропущены по interval) */
probes: PingProbeSnapshot[]
/** Сколько проб пропущено из‑за interval_sec */
skippedByInterval?: number
}
export interface SpeedRunSnapshot {
probeId: string
srcServerId: number
srcServerName: string
dstServerId: number
dstServerName: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: number
ok: boolean
txAvgMbps?: number | null
rxAvgMbps?: number | null
pingRttMs?: number | null
pingLossPct?: number | null
pingError?: string | null
error?: string
}
export interface SpeedScheduledRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "uptime_speed"
sampledAt: string
runs: SpeedRunSnapshot[]
}
/** Узел каталога: успешный GET `/system/identity` (REST) и время ответа. */
export interface ServerRestPingSnapshot {
serverId: number
name: string
host: string
ok: boolean
/** RTT успешного REST-запроса, мс */
latencyMs?: number | null
error?: string
}
export interface ServersRestPingRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "servers_rest_ping"
sampledAt: string
skipped?: boolean
fatalError?: string
servers: ServerRestPingSnapshot[]
}
/** Одна строка диагностики прогона движка (для `scheduler_runs.result_json`). */
export type AlertEngineRuleDiagSnapshot = {
ruleId: string
inGroup: boolean
evalHit: boolean
hitTransition?: "problem" | "recovery" | "neutral"
hitMessage?: string
stabilityOk: boolean
cooldownOk: boolean
telegramOk: boolean
blocked?:
| "no_hit"
| "stability"
| "cooldown"
| "no_telegram"
| "dedupe_positive"
| "in_group"
}
export interface AlertEngineRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "alert_engine"
sampledAt: string
rulesChecked: number
standaloneFires: number
groupFires: number
skippedNoTelegram: boolean
errors?: string[]
/** Почему правило не дало отдельную отправку в этом тике (если массив пуст — движок без правил). */
ruleDiag?: AlertEngineRuleDiagSnapshot[]
}
/** Снимок джобы `gre_bgp`: запись GRE/BGP в SQLite для алерт-движка. */
export interface GreBgpSnapshotRunSnapshot {
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
job: "gre_bgp"
sampledAt: string
skipped?: boolean
fatalError?: string
greWritten: number
bgpWritten: number
/** Текущее состояние GRE на момент прогона (для alert_engine). */
greTunnels?: Array<{
targetLabel: string
status: "up" | "down" | "degraded"
}>
/** Текущее состояние BGP-пиров на момент прогона (для alert_engine). */
bgpPeers?: Array<{
key: string
state: string
}>
errors?: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
| PingRunSnapshot
| SpeedScheduledRunSnapshot
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| AlertEngineRunSnapshot
+10
View File
@@ -0,0 +1,10 @@
/**
* Парсит id сервера из БД (положительное целое).
* Не извлекает цифры из строк вроде «srv1» только целая строка из цифр.
*/
export function parseDbServerId(raw: unknown): number | null {
const s = String(raw ?? "").trim()
if (!/^\d+$/.test(s)) return null
const n = Number.parseInt(s, 10)
return Number.isFinite(n) && n >= 1 ? n : null
}
+118 -29
View File
@@ -30,6 +30,7 @@ import {
GitMergeIcon,
TerminalIcon,
SettingsIcon,
DatabaseIcon,
CableIcon,
ScanLineIcon,
BellIcon,
@@ -39,6 +40,13 @@ import {
BoxIcon,
BadgeCheckIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
type SidebarCountsDto,
} from "@/lib/sidebar-badges"
// ─── Command palette trigger button ──────────────────────────────────────────
@@ -58,69 +66,154 @@ function CommandPaletteButton() {
)
}
// ─────────────────────────────────────────────────────────────────────────────
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
const navGroups: NavGroup[] = [
const navStructure: { label: string; items: NavItemBase[] }[] = [
{
label: "Обзор",
items: [
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon />, badge: "8" },
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon /> },
],
},
{
label: "Данные",
items: [
{ title: "Домены", url: "/domains", icon: <GlobeIcon />, badge: "1.8к" },
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon />, badge: "4.2к" },
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
{ title: "Домены", url: "/domains", icon: <GlobeIcon /> },
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon /> },
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
],
},
{
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon />, badge: "9" },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon />, badge: "6" },
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon />, badge: "4" },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon />, badge: "6" },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon />, badge: "5" },
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon />, badge: "2" },
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon /> },
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
],
},
{
label: "Инструменты",
items: [
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon />, badge: "12" },
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon /> },
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
],
},
{
label: "Система",
items: [
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
],
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { resolvedTheme, setTheme } = useTheme()
const { mode, backendUrl, backendStatus } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), [])
React.useEffect(() => {
queueMicrotask(() => {
setMounted(true)
})
}, [])
React.useEffect(() => {
if (mode !== "live" || backendStatus !== true) {
setLiveCounts(null)
return
}
let cancelled = false
const load = async () => {
try {
const base = backendUrl.replace(/\/$/, "")
const [cRes, gRes] = await Promise.all([
fetch(`${base}/api/sidebar-counts`),
fetch(`${base}/api/filters/gre-tunnels`),
])
if (cancelled) return
if (!cRes.ok) {
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
} catch {
if (!cancelled) setLiveCounts(null)
}
}
void load()
const id = window.setInterval(load, 120_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendStatus, backendUrl])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
if (mode !== "live") return mockBadges[url]
if (backendStatus !== true) return undefined
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
if (!evo.enabled) return mockBadges[url]
if (!evo.snapshot) return undefined
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
return formatSidebarBadgeCount(evo.snapshot.asns.length)
}
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
return undefined
}
return undefined
}
return navStructure.map((g) => ({
label: g.label,
items: g.items.map((it) => ({
title: it.title,
url: it.url,
icon: it.icon,
badge: badgeFor(it.url),
})),
}))
}, [mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
return (
@@ -135,7 +228,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
</div>
</div>
{/* Command palette trigger */}
<CommandPaletteButton />
</SidebarHeader>
<SidebarContent>
@@ -143,16 +235,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
{/* Avatar */}
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
АК
</div>
{/* Name / email */}
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
</div>
{/* Theme toggle — только после mount, иначе SSR и клиент расходятся (next-themes). */}
{mounted ? (
<button
type="button"
+4 -3
View File
@@ -8,7 +8,7 @@ import {
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon,
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
@@ -52,8 +52,9 @@ const ALL_ITEMS: CommandItem[] = [
{ id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: <ScanLineIcon />, keywords: ["probe","ping","traceroute","диагностика"] },
{ id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: <TerminalIcon />, keywords: ["terminal","cli","ssh","консоль"] },
// Система
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
{ id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: <DatabaseIcon />, keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] },
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
]
// ─── Scoring / filtering ──────────────────────────────────────────────────────
+2 -1
View File
@@ -14,6 +14,7 @@ export interface NavItem {
title: string
url: string
icon?: React.ReactNode
/** Если `undefined` — счётчик не показываем (нет данных или раздел без live-источника). */
badge?: string
}
@@ -40,7 +41,7 @@ export function NavMain({ groups }: { groups: NavGroup[] }) {
>
{item.icon}
<span>{item.title}</span>
{item.badge && (
{item.badge != null && item.badge !== "" && (
<span className="ml-auto text-[10.5px] font-mono tabular-nums text-sidebar-foreground/50 group-data-[collapsible=icon]:hidden">
{item.badge}
</span>
+5 -2
View File
@@ -24,8 +24,11 @@ export function wanUplinkIpMatchesGreOuter(
if (!a || !b) return false
if (a === b) return true
if (resolvedIpv4ByHost) {
const r = resolvedIpv4ByHost.get(b.toLowerCase())
if (r && a === normalizeGreEndpointAddr(r)) return true
const rb = resolvedIpv4ByHost.get(b.toLowerCase())
if (rb && a === normalizeGreEndpointAddr(rb)) return true
const ra = resolvedIpv4ByHost.get(a.toLowerCase())
if (ra && b === normalizeGreEndpointAddr(ra)) return true
if (ra && rb && normalizeGreEndpointAddr(ra) === normalizeGreEndpointAddr(rb)) return true
}
return false
}
+180 -1
View File
@@ -1,4 +1,5 @@
import type { GreTunnel, Server } from "@/lib/data"
import type { WanJhEdge } from "@/lib/network-map-layout"
import {
formatGreOuterForDisplay,
normalizeGreEndpointAddr,
@@ -24,6 +25,13 @@ export type GreTunnelProbeEdge = {
toServer: Server
}
/** Неориентированная пара узлов: один ключ для HR→MSK и MSK→HR (две строки GRE в БД). */
export function canonicalGreServerPairKey(a: Pick<Server, "id">, b: Pick<Server, "id">): string {
const x = String(a.id)
const y = String(b.id)
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
}
function normIface(i?: string | null): string {
return (i ?? "").trim().toLowerCase()
}
@@ -122,6 +130,10 @@ function probesForServerPair(
/**
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
*
* Пара узлов **неориентированная**: туннель в БД на HRMSK и зеркальная запись MSKHR
* попадают в одну группу иначе обе стороны независимо выбирают одни и те же пробы
* и бейджи дублируются на разных линиях карты.
*/
export function assignSpeedProbesToGreTunnels(
edges: GreTunnelProbeEdge[],
@@ -132,7 +144,7 @@ export function assignSpeedProbesToGreTunnels(
const byPair = new Map<string, GreTunnelProbeEdge[]>()
for (const e of edges) {
const k = `${String(e.fromServer.id)}\t${String(e.toServer.id)}`
const k = canonicalGreServerPairKey(e.fromServer, e.toServer)
const arr = byPair.get(k) ?? []
arr.push(e)
byPair.set(k, arr)
@@ -308,3 +320,170 @@ export function mergeGreMetricsWithSpeedProbe(
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
}
// ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ──
export function wanJhEdgeMapKey(edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">): string {
return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`
}
function scoreSpeedProbeForWanJhEdge(
p: GreSpeedProbeSnapshot,
edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">,
home: Server,
): number {
if (String(p.srcServerId) !== edge.homeId || String(p.dstServerId) !== edge.jhId) return -1
if (p.enabled === false) return -1
const wanIface = normIface(home.wanUplinks?.[edge.wanIdx]?.iface)
const si = normIface(p.srcInterface)
if (si && wanIface && si !== wanIface) return -1
let s = 0
if (si && wanIface && si === wanIface) s += 100
else if (!si && wanIface) s += 35
else if (!wanIface && si) s += 15
else if (!si && !wanIface) s += 5
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 6
return s
}
function probesHomeToJh(allProbes: GreSpeedProbeSnapshot[], homeId: string, jhId: string): GreSpeedProbeSnapshot[] {
return allProbes.filter((p) => {
if (p.enabled === false) return false
return String(p.srcServerId) === homeId && String(p.dstServerId) === jhId
})
}
function assignSpeedProbesWithinWanJhGroup(
edges: WanJhEdge[],
home: Server,
allProbes: GreSpeedProbeSnapshot[],
): Map<string, GreSpeedProbeSnapshot | undefined> {
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
if (edges.length === 0) return out
const E = [...edges].sort((a, b) => a.wanIdx - b.wanIdx || a.jhId.localeCompare(b.jhId))
const pairProbes = probesHomeToJh(allProbes, E[0].homeId, E[0].jhId)
if (E.length === 1) {
let best: GreSpeedProbeSnapshot | undefined
let bestS = -1
for (const p of pairProbes) {
const sc = scoreSpeedProbeForWanJhEdge(p, E[0], home)
if (sc > bestS) {
bestS = sc
best = p
}
}
out.set(wanJhEdgeMapKey(E[0]), best != null && bestS >= 0 ? best : undefined)
return out
}
const n = E.length
const m = pairProbes.length
if (m === 0) {
for (const e of E) out.set(wanJhEdgeMapKey(e), undefined)
return out
}
let bestSum = -Infinity
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
if (i === n) {
let sum = 0
for (const e of E) {
const p = cur.get(wanJhEdgeMapKey(e))
if (!p) return
sum += scoreSpeedProbeForWanJhEdge(p, e, home)
}
if (sum > bestSum) {
bestSum = sum
bestAssign = new Map(cur)
}
return
}
const edge = E[i]!
const slotKey = wanJhEdgeMapKey(edge)
for (const p of pairProbes) {
if (used.has(p.id)) continue
if (scoreSpeedProbeForWanJhEdge(p, edge, home) < 0) continue
used.add(p.id)
cur.set(slotKey, p)
dfs(i + 1, used, cur)
cur.delete(slotKey)
used.delete(p.id)
}
}
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
if (inject !== null && inject.size === n) {
for (const ed of E) out.set(wanJhEdgeMapKey(ed), inject.get(wanJhEdgeMapKey(ed)))
return out
}
const used = new Set<string>()
const remaining = new Set(E.map((e) => wanJhEdgeMapKey(e)))
while (remaining.size > 0) {
let pickEdge: WanJhEdge | undefined
let pickProbe: GreSpeedProbeSnapshot | undefined
let pickScore = -Infinity
for (const key of remaining) {
const e = E.find((x) => wanJhEdgeMapKey(x) === key)!
for (const p of pairProbes) {
if (used.has(p.id)) continue
const sc = scoreSpeedProbeForWanJhEdge(p, e, home)
if (sc < 0) continue
if (
sc > pickScore ||
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
) {
pickScore = sc
pickEdge = e
pickProbe = p
}
}
}
if (!pickEdge || !pickProbe) {
for (const key of remaining) out.set(key, undefined)
break
}
out.set(wanJhEdgeMapKey(pickEdge), pickProbe)
used.add(pickProbe.id)
remaining.delete(wanJhEdgeMapKey(pickEdge))
}
return out
}
/**
* Назначает speed-пробы сегментам WANJH (Home спутник JH): по паре серверов и iface источника,
* без повторного использования одной пробы на два WAN одного homejh (RT / MTS).
*/
export function assignSpeedProbesToWanJhEdges(
edges: WanJhEdge[],
servers: Server[],
probes: GreSpeedProbeSnapshot[],
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const))
const byPair = new Map<string, WanJhEdge[]>()
for (const e of edges) {
const k = `${e.homeId}\t${e.jhId}`
const arr = byPair.get(k) ?? []
arr.push(e)
byPair.set(k, arr)
}
for (const group of byPair.values()) {
const home = homeById.get(group[0].homeId)
if (!home) {
for (const e of group) result.set(wanJhEdgeMapKey(e), undefined)
continue
}
const assigned = assignSpeedProbesWithinWanJhGroup(group, home, probes)
for (const [k, sp] of assigned) result.set(k, sp)
}
return result
}
+172 -11
View File
@@ -46,7 +46,7 @@ export const NETWORK_MAP_PIPELINE_Y = 300
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
*/
export const NETWORK_MAP_LAYOUT_REVISION = 3
export const NETWORK_MAP_LAYOUT_REVISION = 5
export interface WanJhEdge {
homeId: string
@@ -106,6 +106,41 @@ function spreadInSegment(count: number, index: number, xMin: number, xMax: numbe
return xMin + (index / (count - 1)) * (xMax - xMin)
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n))
}
function resolveLaneCollisions(
ids: string[],
nodePos: Record<string, { x: number; y: number }>,
minY: number,
maxY: number,
minGap: number,
): void {
if (ids.length <= 1) {
if (ids.length === 1) {
const id = ids[0]
const p = nodePos[id]
if (p) p.y = clamp(p.y, minY, maxY)
}
return
}
const sorted = ids
.map((id) => ({ id, y: nodePos[id]?.y ?? (minY + maxY) / 2 }))
.sort((a, b) => a.y - b.y)
const n = sorted.length
const maxSpan = maxY - minY
const gap = Math.min(minGap, maxSpan / (n - 1))
const center = sorted.reduce((acc, s) => acc + s.y, 0) / n
const span = gap * (n - 1)
const start = clamp(center - span / 2, minY, maxY - span)
for (let i = 0; i < n; i++) {
const p = nodePos[sorted[i].id]
if (!p) continue
p.y = start + i * gap
}
}
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
export function computeNetworkMapLayout(servers: Server[]): {
nodePos: Record<string, { x: number; y: number }>
@@ -119,21 +154,70 @@ export function computeNetworkMapLayout(servers: Server[]): {
const laneW = (span - 2 * laneGap) / 3
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
const laneByType = new Map<ServerType, Server[]>()
const minNodeY = MARGIN + 70
const maxNodeY = H - 72
const laneMinGap = 110
const typeYOffset: Record<ServerType, number> = {
"home-router": -14,
"jump-host": 0,
"exit-node": 14,
}
const siteWeight = (s: Server): number => {
const typeBoost = s.type === "jump-host" ? 3 : s.type === "home-router" ? 2 : 1
const statusBoost = s.status === "online" ? 2 : s.status === "degraded" ? 1 : 0
return typeBoost + statusBoost
}
const siteNames = [
...new Set(
servers
.map((s) => s.site?.trim())
.filter((v): v is string => Boolean(v)),
),
]
siteNames.sort((a, b) => {
const wa = servers.filter((s) => s.site === a).reduce((acc, s) => acc + siteWeight(s), 0)
const wb = servers.filter((s) => s.site === b).reduce((acc, s) => acc + siteWeight(s), 0)
if (wb !== wa) return wb - wa
return a.localeCompare(b)
})
const siteY = new Map<string, number>()
siteNames.forEach((site, i) => {
siteY.set(site, spreadInSegment(siteNames.length, i, minNodeY, maxNodeY))
})
const fallbackY = (minNodeY + maxNodeY) / 2
let xLane = MARGIN
for (const type of laneOrder) {
const group = servers.filter((s) => s.type === type)
const group = servers
.filter((s) => s.type === type)
.slice()
.sort((a, b) => `${a.site}|${a.name}|${a.id}`.localeCompare(`${b.site}|${b.name}|${b.id}`))
laneByType.set(type, group)
const xMin = xLane
const xMax = xLane + laneW
const laneCenterX = (xMin + xMax) / 2
group.forEach((s, i) => {
const baseline = siteY.get(s.site) ?? fallbackY
const siblings = group.filter((g) => g.site === s.site)
const sibIdx = siblings.findIndex((g) => g.id === s.id)
const spread = siblings.length <= 1 ? 0 : (sibIdx - (siblings.length - 1) / 2) * 34
nodePos[s.id] = {
x: spreadInSegment(group.length, i, xMin, xMax),
y: NETWORK_MAP_PIPELINE_Y,
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
x: laneCenterX,
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
}
})
xLane += laneW + laneGap
}
const hr = servers.filter((s) => s.type === "home-router")
const homes = laneByType.get("home-router") ?? []
const jhs = laneByType.get("jump-host") ?? []
const exits = laneByType.get("exit-node") ?? []
resolveLaneCollisions(homes.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
resolveLaneCollisions(jhs.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
resolveLaneCollisions(exits.map((s) => s.id), nodePos, minNodeY, maxNodeY, laneMinGap)
const hr = homes
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
@@ -142,7 +226,7 @@ export function computeNetworkMapLayout(servers: Server[]): {
const base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
const n = wans.length
const satX = (base.x + jhColumnCenterX) / 2
const rowY = NETWORK_MAP_PIPELINE_Y
const rowY = base.y
const maxV = H - 2 * MARGIN - 100
const vGap =
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
@@ -163,8 +247,9 @@ export function computeNetworkMapLayout(servers: Server[]): {
* Отдельного ICMP по ребру нет это не замер линии, а сумма каталожных latency концов.
*/
function legPing(home: Server, jh: Server): number | null {
const hOk = home.enabled && home.status !== "offline"
const jOk = jh.enabled && jh.status !== "offline"
/** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
const hOk = home.status !== "offline"
const jOk = jh.status !== "offline"
if (!hOk || !jOk) return null
if (home.latency == null || jh.latency == null) return null
return Math.min(995, Math.round(home.latency + jh.latency))
@@ -172,8 +257,8 @@ function legPing(home: Server, jh: Server): number | null {
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
const homes = servers.filter((s) => s.type === "home-router" && s.enabled)
const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
const homes = servers.filter((s) => s.type === "home-router")
const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
if (homes.length === 0 || jhs.length === 0) return []
const edges: WanJhEdge[] = []
@@ -307,6 +392,41 @@ function hostWithoutPort(host: string): string {
return h
}
function normalizeIpNoMask(raw: string | null | undefined): string {
if (!raw) return ""
return normalizeGreEndpoint(raw.split("/")[0] ?? "")
}
function canonicalIpPairKey(aRaw: string, bRaw: string): string | null {
const a = normalizeGreEndpoint(aRaw)
const b = normalizeGreEndpoint(bRaw)
if (!a || !b || a === "0.0.0.0" || b === "0.0.0.0") return null
const p1 = a <= b ? a : b
const p2 = a <= b ? b : a
return `${p1}|${p2}`
}
function canonicalOuterPairKey(
t: GreTunnel,
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): string | null {
const la = greOuterComparableIps(t.localAddress, resolvedIpv4ByHost)
const ra = greOuterComparableIps(t.remoteAddress, resolvedIpv4ByHost)
const left = [...new Set(la.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
const right = [...new Set(ra.map((x) => normalizeGreEndpoint(x)).filter((x) => x && x !== "0.0.0.0"))]
if (left.length === 0 || right.length === 0) return null
let best: string | null = null
for (const l of left) {
for (const r of right) {
const p1 = l <= r ? l : r
const p2 = l <= r ? r : l
const k = `${p1}|${p2}`
if (best == null || k < best) best = k
}
}
return best
}
/**
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
* Для FQDN в remote-address передайте карту DNS из `/api/network/resolve-hosts`.
@@ -432,6 +552,36 @@ export function buildGreMapEdges(
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
): GreMapEdge[] {
const out: GreMapEdge[] = []
const seenByLink = new Map<string, number>()
const statusRank: Record<GreTunnel["status"], number> = {
up: 3,
degraded: 2,
down: 1,
}
function tunnelPriority(t: GreTunnel): number {
return (t.enabled ? 100 : 0) + (statusRank[t.status] ?? 0)
}
function canonicalLinkKey(t: GreTunnel, a: Server, b: Server): string {
const s1 = a.id <= b.id ? a.id : b.id
const s2 = a.id <= b.id ? b.id : a.id
// 1) Предпочитаем inner-пару: у directional A→B/B→A она одинакова по /30.
const inner = canonicalIpPairKey(
normalizeIpNoMask(t.localInnerIp),
normalizeIpNoMask(t.remoteInnerIp),
)
if (inner) return `${s1}|${s2}|inner:${inner}`
// 2) Иначе outer-пара с учетом DNS/FQDN→IPv4 сопоставления.
const outer = canonicalOuterPairKey(t, resolvedIpv4ByHost)
if (outer) return `${s1}|${s2}|outer:${outer}`
// 3) Крайний случай: чтобы не схлопнуть потенциально разные линki.
return `${s1}|${s2}|id:${t.id}`
}
for (const t of tunnels) {
const fromServer = servers.find((s) => s.id === t.serverId)
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
@@ -440,7 +590,18 @@ export function buildGreMapEdges(
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
const to = greEndpointAnchor(toServer, t, "peer", nodePos, wanSatPos, resolvedIpv4ByHost)
out.push({ tunnel: t, from, to, fromServer, toServer })
const edge: GreMapEdge = { tunnel: t, from, to, fromServer, toServer }
const key = canonicalLinkKey(t, fromServer, toServer)
const prevIdx = seenByLink.get(key)
if (prevIdx == null) {
seenByLink.set(key, out.length)
out.push(edge)
continue
}
const prev = out[prevIdx]
if (tunnelPriority(t) > tunnelPriority(prev.tunnel)) {
out[prevIdx] = edge
}
}
return out
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Порог RTT (мс) для визуала «деградации» на `/uptime` должен совпадать с
* `PING_PROBE_WARN_RTT_MS` в `backend/src/constants/ping-probe.ts` (логика `parsePing`).
*/
export const PING_PROBE_WARN_RTT_MS = 120
+223 -13
View File
@@ -3,7 +3,7 @@
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
*/
import type { FilterRule } from "@/lib/data"
import { servers, type FilterRule } from "@/lib/data"
// ── Совместимо с app/(main)/route-optimizer/page.tsx ─────────────────────────
@@ -130,6 +130,15 @@ export interface OptimizerApiServer {
status: "online" | "offline" | null
latency: number | null
model: string | null
wanUplinks?: Array<{
id: string
name: string
isp: string
iface: string
ip: string
maxDl: number
maxUl: number
}>
}
export interface FiltersRulesetRow {
@@ -137,10 +146,29 @@ export interface FiltersRulesetRow {
rules: FilterRule[]
}
function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) {
/** Снимок speed-probe из /api/uptime/speed-probes (live-источник ping/speed по интерфейсам). */
export interface RouteOptimizerSpeedProbe {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
enabled: boolean
lastPingRttMs: number | null
lastPingLossPct: number | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastPingAt: string | null
}
/**
* Единый расчёт score для Route AI (используется в /route-optimizer и связанных оптимизаторах).
* pingWeight = вес ping в процентах (0..100), оставшийся вес идёт в speed.
*/
export 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 = pw / 100
const w = pingWeight / 100
return Math.round(w * pingScore + (1 - w) * speedScore)
}
@@ -160,6 +188,45 @@ function syntheticWans(home: OptimizerApiServer): WanUplink[] {
}]
}
function normalizeApiWanUplinks(list: OptimizerApiServer["wanUplinks"]): WanUplink[] {
const src = Array.isArray(list) ? list : []
const out = src
.map((w, idx) => ({
id: String(w.id || `api-w-${idx + 1}`),
name: String(w.name || `WAN${idx + 1}`),
isp: String(w.isp || "—"),
iface: String(w.iface || "").trim(),
ip: String(w.ip || ""),
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)),
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)),
}))
.filter((w) => w.iface.length > 0)
return out
}
function catalogWansForHome(homeId: string, homeHost?: string, homeName?: string): WanUplink[] | null {
const hostNorm = (homeHost ?? "").trim().toLowerCase()
const nameNorm = (homeName ?? "").trim().toLowerCase()
const row = servers.find((s) => {
if (s.type !== "home-router") return false
if (String(s.id) === homeId) return true
if (hostNorm && String(s.host ?? "").trim().toLowerCase() === hostNorm) return true
if (nameNorm && String(s.name ?? "").trim().toLowerCase() === nameNorm) return true
return false
})
const list = row?.wanUplinks ?? []
if (!list.length) return null
return list.map((w, idx) => ({
id: w.id || `w-${homeId}-${idx + 1}`,
name: w.name || `WAN${idx + 1}`,
isp: w.isp || "—",
iface: w.iface || "",
ip: w.ip || "",
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 1000)),
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 1000)),
}))
}
function legPing(
a: number | null,
b: number | null,
@@ -202,7 +269,10 @@ export function mapApiServersToTopology(rows: OptimizerApiServer[]): {
country: s.country || "UN",
model: s.model ?? "—",
ip: s.host,
wans: syntheticWans(s),
wans:
normalizeApiWanUplinks(s.wanUplinks).length > 0
? normalizeApiWanUplinks(s.wanUplinks)
: (catalogWansForHome(String(s.id), s.host, s.name) ?? syntheticWans(s)),
}))
const jumpHosts: JumpHost[] = rows
@@ -236,6 +306,7 @@ export function buildLiveOptimizerData(
rows: OptimizerApiServer[],
rulesets: FiltersRulesetRow[] | null,
settings: OptimizerSettings,
speedProbes: RouteOptimizerSpeedProbe[] = [],
): OptimizerData {
const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows)
const pw = settings.pingWeight
@@ -255,13 +326,142 @@ export function buildLiveOptimizerData(
const homeLat = homeRow?.latency ?? null
const wanJhLegs: WanJhLeg[] = []
for (const wan of home.wans) {
for (const jh of jumpHosts) {
const probesForHome = speedProbes
.filter((p) => p.enabled !== false && p.srcServerId === home.id)
function scoreProbeForWanIface(p: RouteOptimizerSpeedProbe, wanIface: string): number {
const want = wanIface.trim().toLowerCase()
const got = String(p.srcInterface ?? "").trim().toLowerCase()
if (got && want && got !== want) return -1
const ifaceScore =
got && want && got === want ? 100
: (!got && want) ? 35
: (!want && 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 ? 20 : 0)
+ (p.lastTxAvgMbps != null ? 10 : 0)
+ (p.lastRxAvgMbps != null ? 10 : 0)
+ freshness
}
function assignProbesForWanJh(jhId: string): Map<string, RouteOptimizerSpeedProbe | undefined> {
const out = new Map<string, RouteOptimizerSpeedProbe | undefined>()
const pool = probesForHome.filter((p) => p.dstServerId === jhId)
if (!pool.length) {
for (const wan of home.wans) out.set(wan.id, undefined)
return out
}
let bestSum = -Infinity
let bestAssign: Map<string, RouteOptimizerSpeedProbe> | null = null
const wanList = [...home.wans]
function dfs(i: number, used: Set<string>, cur: Map<string, RouteOptimizerSpeedProbe>, sum: number) {
if (i === wanList.length) {
if (sum > bestSum) {
bestSum = sum
bestAssign = new Map(cur)
}
return
}
const wan = wanList[i]!
let picked = false
for (const p of pool) {
if (used.has(p.id)) continue
const sc = scoreProbeForWanIface(p, wan.iface)
if (sc < 0) continue
picked = true
used.add(p.id)
cur.set(wan.id, p)
dfs(i + 1, used, cur, sum + sc)
cur.delete(wan.id)
used.delete(p.id)
}
if (!picked) dfs(i + 1, used, cur, sum)
}
if (wanList.length <= 7 && wanList.length <= pool.length) {
dfs(0, new Set(), new Map(), 0)
}
if (bestAssign) {
for (const wan of home.wans) out.set(wan.id, bestAssign.get(wan.id))
return out
}
const used = new Set<string>()
for (const wan of home.wans) {
let best: RouteOptimizerSpeedProbe | undefined
let bestScore = -1
for (const p of pool) {
if (used.has(p.id)) continue
const sc = scoreProbeForWanIface(p, wan.iface)
if (sc > bestScore) {
bestScore = sc
best = p
}
}
if (best && bestScore >= 0) {
out.set(wan.id, best)
used.add(best.id)
} else {
out.set(wan.id, undefined)
}
}
return out
}
function pickProbeForJhExit(jhId: string, exitId: string): RouteOptimizerSpeedProbe | undefined {
const pool = speedProbes.filter((p) => {
if (p.enabled === false) return false
const a = p.srcServerId === jhId && p.dstServerId === exitId
const b = p.srcServerId === exitId && p.dstServerId === jhId
return a || b
})
if (!pool.length) return undefined
let best: RouteOptimizerSpeedProbe | undefined
let bestScore = -1
for (const p of pool) {
const forward = p.srcServerId === jhId && p.dstServerId === exitId
const freshness =
p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0
const score =
(forward ? 5 : 0)
+ (p.lastPingRttMs != null ? 25 : 0)
+ (p.lastTxAvgMbps != null ? 12 : 0)
+ (p.lastRxAvgMbps != null ? 12 : 0)
+ freshness
if (score > bestScore) {
bestScore = score
best = p
}
}
return best
}
for (const jh of jumpHosts) {
const assignedByWanId = assignProbesForWanJh(jh.id)
for (const wan of home.wans) {
const jhRow = byId.get(jh.id)
const jhOnline = jhRow?.status === "online"
const ping = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
const { dl, ul } = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
const loss = !homeOnline || !jhOnline ? 100 : 0
const probe = assignedByWanId.get(wan.id)
const modelPing = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
const ping = probe?.lastPingRttMs != null
? Math.max(1, Math.round(probe.lastPingRttMs))
: modelPing
const modelBw = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
const dl = probe?.lastTxAvgMbps != null
? Math.max(1, Math.min(wan.maxDl, Math.round(probe.lastTxAvgMbps)))
: modelBw.dl
const ul = probe?.lastRxAvgMbps != null
? Math.max(1, Math.min(wan.maxUl, Math.round(probe.lastRxAvgMbps)))
: modelBw.ul
const loss = probe?.lastPingLossPct != null
? Math.max(0, Math.min(100, Math.round(probe.lastPingLossPct)))
: (!homeOnline || !jhOnline ? 100 : 0)
wanJhLegs.push({
wanId: wan.id,
jhId: jh.id,
@@ -269,7 +469,7 @@ export function buildLiveOptimizerData(
dlMbps: dl,
ulMbps: ul,
loss,
score: calcScore(ping, dl, ul, pw),
score: calcRouteScore(ping, dl, ul, pw),
})
}
}
@@ -280,8 +480,18 @@ export function buildLiveOptimizerData(
const jhRow = byId.get(jh.id)
const exRow = byId.get(ex.id)
const ok = jhRow?.status === "online" && exRow?.status === "online"
const ping = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
const { dl, ul } = legBandwidthMbps(ping, 1000, 1000)
const probe = pickProbeForJhExit(jh.id, ex.id)
const modelPing = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
const ping = probe?.lastPingRttMs != null
? Math.max(1, Math.round(probe.lastPingRttMs))
: modelPing
const modelBw = legBandwidthMbps(ping, 1000, 1000)
const dl = probe?.lastTxAvgMbps != null
? Math.max(1, Math.round(probe.lastTxAvgMbps))
: modelBw.dl
const ul = probe?.lastRxAvgMbps != null
? Math.max(1, Math.round(probe.lastRxAvgMbps))
: modelBw.ul
jhExMap.set(`${jh.id}::${ex.id}`, {
jhId: jh.id,
exitId: ex.id,
@@ -302,7 +512,7 @@ export function buildLiveOptimizerData(
const totalPing = hw.pingMs + je.pingMs
const dl = Math.min(hw.dlMbps, je.dlMbps)
const ul = Math.min(hw.ulMbps, je.ulMbps)
const score = calcScore(totalPing, dl, ul, pw)
const score = calcRouteScore(totalPing, dl, ul, pw)
fullRoutes.push({
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
homeId: home.id,
+179
View File
@@ -0,0 +1,179 @@
/**
* Снимок прогона планировщика (JSON из `scheduler_runs.result_json`).
* Дублирует контракт `backend/src/types/scheduler-run-snapshot.ts` для UI.
*/
export interface ServerRestPingSnapshot {
serverId: number
name: string
host: string
ok: boolean
latencyMs?: number | null
error?: string
}
export interface ServersRestPingRunSnapshot {
v: number
job: "servers_rest_ping"
sampledAt: string
skipped?: boolean
fatalError?: string
servers: ServerRestPingSnapshot[]
}
export type AlertEngineRuleDiagSnapshot = {
ruleId: string
inGroup: boolean
evalHit: boolean
stabilityOk: boolean
cooldownOk: boolean
telegramOk: boolean
blocked?:
| "no_hit"
| "stability"
| "cooldown"
| "no_telegram"
| "dedupe_positive"
| "in_group"
}
/** Сводка прогона движка оповещений (JSON из `scheduler_runs` для job `alert_engine`). */
export interface AlertEngineRunSnapshot {
v: number
job: "alert_engine"
sampledAt: string
rulesChecked: number
standaloneFires: number
groupFires: number
skippedNoTelegram: boolean
errors?: string[]
ruleDiag?: AlertEngineRuleDiagSnapshot[]
}
export interface GreBgpSnapshotRunSnapshot {
v: number
job: "gre_bgp"
sampledAt: string
skipped?: boolean
fatalError?: string
greWritten: number
bgpWritten: number
errors?: string[]
}
export type SchedulerRunSnapshot =
| TrafficRunSnapshot
| ResourcesRunSnapshot
| PingRunSnapshot
| SpeedScheduledRunSnapshot
| ServersRestPingRunSnapshot
| GreBgpSnapshotRunSnapshot
| AlertEngineRunSnapshot
export interface TrafficServerSnapshot {
serverId: number
name: string
host: string
ok: boolean
error?: string
interfaces?: number
sumRxMbps?: number
sumTxMbps?: number
}
export interface TrafficRunSnapshot {
v: number
job: "traffic"
sampledAt: string
skipped?: boolean
fatalError?: string
servers: TrafficServerSnapshot[]
}
export interface ResourceServerSnapshot {
serverId: number
name: string
host: string
status: "online" | "offline"
error?: string
cpuLoadPct?: number
memUsedMb?: number
memTotalMb?: number
memUsedPct?: number
diskFreeMb?: number
diskTotalMb?: number
uptimeSeconds?: number
boardName?: string
rosVersion?: string
}
export interface ResourcesRunSnapshot {
v: number
job: "uptime_resources"
sampledAt: string
skipped?: boolean
fatalError?: string
servers: ResourceServerSnapshot[]
}
export interface PingProbeSnapshot {
probeId: string
name: string
target: string
srcServerId: number
srcServerName: string
srcInterface: string
ok: boolean
rttMs: number | null
lossPct: number
status: "up" | "warn" | "down"
error?: string
}
export interface PingRunSnapshot {
v: number
job: "uptime_ping"
sampledAt: string
skipped?: boolean
fatalError?: string
probes: PingProbeSnapshot[]
skippedByInterval?: number
}
export interface SpeedRunSnapshot {
probeId: string
srcServerId: number
srcServerName: string
dstServerId: number
dstServerName: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: number
ok: boolean
txAvgMbps?: number | null
rxAvgMbps?: number | null
pingRttMs?: number | null
pingLossPct?: number | null
pingError?: string | null
error?: string
}
export interface SpeedScheduledRunSnapshot {
v: number
job: "uptime_speed"
sampledAt: string
runs: SpeedRunSnapshot[]
}
export function parseSchedulerRunSnapshot(raw: string | null | undefined): SchedulerRunSnapshot | null {
if (raw == null || raw.trim() === "") return null
try {
const o = JSON.parse(raw) as { v?: unknown; job?: string }
if (o?.v !== 1 || typeof o.job !== "string") return null
return o as SchedulerRunSnapshot
} catch {
return null
}
}
+83
View File
@@ -0,0 +1,83 @@
/** Типы и подписи для UI планировщика / страницы «Сбор данных» (совпадают с ответами API). */
/** Последний ключ — движок оповещений (читает БД после коллекторов; см. описание `alert_engine`). */
export const SCHEDULER_JOB_KEYS = [
"traffic",
"servers_rest_ping",
"uptime_resources",
"uptime_ping",
"uptime_speed",
"gre_bgp",
"alert_engine",
] as const
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
export const SCHEDULER_JOB_LABELS: Record<string, string> = {
traffic: "Трафик",
servers_rest_ping: "Серверы: REST API",
uptime_resources: "Uptime: ресурсы",
uptime_ping: "Uptime: ping",
uptime_speed: "Uptime: speed",
gre_bgp: "GRE + BGP",
alert_engine: "Оповещения",
}
/** Кратко — для раскрытой карточки прогона и подсказок. */
export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в SQLite.",
servers_rest_ping:
"Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.",
uptime_resources: "CPU, память, температура и др. метрики с устройств.",
uptime_ping: "ICMP-пинг по настроенным пробам мониторинга.",
uptime_speed: "Фоновые btest / speed-пробы между узлами (при включённых пробах).",
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
alert_engine:
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
}
export interface CollectorSettingsDto {
enabled: boolean
intervalSec: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays: number
lastCollectedAt: string | null
lastDurationMs: number | null
lastError: string | null
collectorRunning?: boolean
}
export interface SchedulerJobStatusDto {
jobKey: string
enabled: boolean
intervalSec: number
running: boolean
lastFinishedAt: string | null
lastStatus: string | null
lastDurationMs: number | null
lastError: string | null
}
export interface SchedulerStatusDto {
jobs: SchedulerJobStatusDto[]
}
export interface UptimeSettingsDto extends CollectorSettingsDto {
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
scheduler?: SchedulerStatusDto
}
export interface SchedulerRunRowDto {
id: string
jobKey: string
startedAt: string
finishedAt: string
status: string
error: string | null
durationMs: number
/** JSON снимка результатов прогона (серверы, пробы, speed и т.д.) */
resultJson?: string | null
}
+55
View File
@@ -0,0 +1,55 @@
import {
asns,
domains,
filters,
greTunnels,
ipRanges,
pingProbes,
routerCertificates,
routerContainers,
servers,
} from "@/lib/data"
/** Число мок-сессий BGP (см. `SESSIONS` в `app/(main)/bgp/page.tsx`). */
export const MOCK_BGP_SESSION_COUNT = 12
/** Компактная подпись: тысячи как «1.8к» (кириллица), как в старом меню. */
export function formatSidebarBadgeCount(n: number): string {
if (!Number.isFinite(n) || n < 0) return "0"
if (n < 1000) return String(Math.round(n))
const k = n / 1000
const s = k >= 10 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, "")
return `${s}к`
}
function mockWireGuardIfacesCount(): number {
let n = 0
for (const s of servers) n += s.wireGuardIfaces?.length ?? 0
return n
}
/** Счётчики из `lib/data` для режима mock. */
export function mockSidebarBadgesByUrl(): Record<string, string> {
return {
"/uptime": formatSidebarBadgeCount(pingProbes.length),
"/domains": formatSidebarBadgeCount(domains.length),
"/ip-ranges": formatSidebarBadgeCount(ipRanges.length),
"/asns": formatSidebarBadgeCount(asns.length),
"/servers": formatSidebarBadgeCount(servers.length),
"/filters": formatSidebarBadgeCount(filters.length),
"/wireguard": formatSidebarBadgeCount(mockWireGuardIfacesCount()),
"/gre": formatSidebarBadgeCount(greTunnels.length),
"/containers": formatSidebarBadgeCount(routerContainers.length),
"/certificates": formatSidebarBadgeCount(routerCertificates.length),
"/bgp": formatSidebarBadgeCount(MOCK_BGP_SESSION_COUNT),
}
}
export interface SidebarCountsDto {
servers: number
filterRules: number
uptimeProbes: number
uptimeSpeedProbes: number
monitoringItems: number
recursiveRoutes: number
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
File diff suppressed because one or more lines are too long