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 <[email protected]>
This commit is contained in:
@@ -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
@@ -1,10 +1,50 @@
|
|||||||
node_modules
|
# dependencies
|
||||||
.next
|
node_modules/
|
||||||
dist
|
|
||||||
build
|
# next.js
|
||||||
coverage
|
.next/
|
||||||
*.tsbuildinfo
|
out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.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
File diff suppressed because it is too large
Load Diff
@@ -57,8 +57,8 @@ export default function AsnsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={rows}
|
data={rows}
|
||||||
searchPlaceholder="Поиск по ASN, организации…"
|
searchPlaceholder="Поиск по ASN, имени, префиксам…"
|
||||||
searchKeys={["asn", "org", "country"]}
|
searchKeys={["asn", "org", "prefixes"]}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: "asn",
|
key: "asn",
|
||||||
@@ -67,13 +67,12 @@ export default function AsnsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "org",
|
key: "org",
|
||||||
label: "Организация",
|
label: "Имя / организация",
|
||||||
render: (d) => <span className="font-medium">{d.org}</span>,
|
render: (d) => (
|
||||||
},
|
<span className="font-medium max-w-[min(28rem,50vw)] truncate block" title={d.org}>
|
||||||
{
|
{d.org}
|
||||||
key: "country",
|
</span>
|
||||||
label: "Страна",
|
),
|
||||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "prefixes",
|
key: "prefixes",
|
||||||
|
|||||||
@@ -279,13 +279,13 @@ export default function CommunitiesPage() {
|
|||||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right font-mono text-xs">
|
<td className="px-4 py-2.5 text-right font-mono text-xs tabular-nums">
|
||||||
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
|
{c.prefixCount.toLocaleString("ru-RU")}
|
||||||
</td>
|
</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">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<ServerIcon className="size-3 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
@@ -337,8 +337,8 @@ export default function CommunitiesPage() {
|
|||||||
{[
|
{[
|
||||||
["Тип", TYPE_LABELS[selected.type]],
|
["Тип", TYPE_LABELS[selected.type]],
|
||||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||||
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
|
["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")],
|
||||||
["Серверов", String(selected.serverCount)],
|
["Серверов", selected.serverCount.toLocaleString("ru-RU")],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex justify-between gap-2">
|
<div key={k} className="flex justify-between gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{k}</span>
|
<span className="text-xs text-muted-foreground">{k}</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+159
-20
@@ -27,12 +27,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
collectGreEndpointHostnames,
|
collectGreEndpointHostnames,
|
||||||
greResolvedMapFromApi,
|
greResolvedMapFromApi,
|
||||||
|
isIpv4String,
|
||||||
|
normalizeGreEndpointAddr,
|
||||||
} from "@/lib/gre-endpoint-resolve"
|
} from "@/lib/gre-endpoint-resolve"
|
||||||
import {
|
import {
|
||||||
assignSpeedProbesToGreTunnels,
|
assignSpeedProbesToGreTunnels,
|
||||||
|
assignSpeedProbesToWanJhEdges,
|
||||||
|
canonicalGreServerPairKey,
|
||||||
greOuterSummaryLine,
|
greOuterSummaryLine,
|
||||||
ifaceNameForGreOuterIp,
|
ifaceNameForGreOuterIp,
|
||||||
mergeGreMetricsWithSpeedProbe,
|
mergeGreMetricsWithSpeedProbe,
|
||||||
|
wanJhEdgeMapKey,
|
||||||
type GreSpeedProbeSnapshot,
|
type GreSpeedProbeSnapshot,
|
||||||
} from "@/lib/map-gre-speed-probe"
|
} from "@/lib/map-gre-speed-probe"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -318,6 +323,12 @@ function edgeBadgePosition(
|
|||||||
|
|
||||||
type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router"
|
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 ───────────────────────────────────────────────────────
|
// ─── SVG sub-components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */
|
/** Бейдж на ребре 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" />
|
<rect width={W} height={H} fill="#060d1a" />
|
||||||
{/* simplified edges */}
|
{/* simplified edges */}
|
||||||
{greEdges.map((e) => (
|
{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" />
|
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
|
||||||
))}
|
))}
|
||||||
{/* WAN edges */}
|
{/* WAN edges */}
|
||||||
@@ -831,7 +842,18 @@ export default function NetworkMapPage() {
|
|||||||
lastPingRttMs: p.lastPingRttMs,
|
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) {
|
if (hostnames.length > 0) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ results: Record<string, string | null> }>(
|
const res = await apiFetch<{ results: Record<string, string | null> }>(
|
||||||
@@ -900,6 +922,10 @@ export default function NetworkMapPage() {
|
|||||||
try {
|
try {
|
||||||
const k = "mm-network-map-layout-rev"
|
const k = "mm-network-map-layout-rev"
|
||||||
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
|
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
|
||||||
|
// Важно: без явного сброса старые drag-координаты остаются в state
|
||||||
|
// и перекрывают новый авто-лейаут на live-данных.
|
||||||
|
setNodePositions({})
|
||||||
|
setSatPositions({})
|
||||||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -957,12 +983,21 @@ export default function NetworkMapPage() {
|
|||||||
[mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap],
|
[mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */
|
/** Speed-пробы на сегментах WAN→JH (тот же формат бейджа, что на GRE JH↔EN). */
|
||||||
|
const speedProbeByWanJhKey = useMemo(
|
||||||
|
() => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes),
|
||||||
|
[wanJhEdges, mapServers, speedProbes],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу
|
||||||
|
* (JH или exit — иначе «второй» бейдж speed посередине сегмента).
|
||||||
|
*/
|
||||||
const suppressWanJhPingBadge = useMemo(() => {
|
const suppressWanJhPingBadge = useMemo(() => {
|
||||||
const s = new Set<string>()
|
const s = new Set<string>()
|
||||||
for (const g of greEdges) {
|
for (const g of greEdges) {
|
||||||
if (g.fromServer.type !== "home-router") continue
|
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)
|
const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap)
|
||||||
if (widx == null) continue
|
if (widx == null) continue
|
||||||
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
|
s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`)
|
||||||
@@ -986,7 +1021,7 @@ export default function NetworkMapPage() {
|
|||||||
() =>
|
() =>
|
||||||
assignSpeedProbesToGreTunnels(
|
assignSpeedProbesToGreTunnels(
|
||||||
greEdges.map((e) => ({
|
greEdges.map((e) => ({
|
||||||
tunnel: e.tunnel,
|
tunnel: { ...e.tunnel, id: greEdgeKey(e) },
|
||||||
fromServer: e.fromServer,
|
fromServer: e.fromServer,
|
||||||
toServer: e.toServer,
|
toServer: e.toServer,
|
||||||
})),
|
})),
|
||||||
@@ -996,14 +1031,77 @@ export default function NetworkMapPage() {
|
|||||||
[greEdges, speedProbes, greResolvedMap],
|
[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 между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
|
/** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */
|
||||||
const greBadgeStaggerByTunnelId = useMemo(() => {
|
const greBadgeStaggerByTunnelId = useMemo(() => {
|
||||||
const pairOrder = new Map<string, number>()
|
const pairOrder = new Map<string, number>()
|
||||||
const out = new Map<string, number>()
|
const out = new Map<string, number>()
|
||||||
for (const ge of greEdges) {
|
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
|
const lane = pairOrder.get(k) ?? 0
|
||||||
out.set(ge.tunnel.id, lane)
|
out.set(greEdgeKey(ge), lane)
|
||||||
pairOrder.set(k, lane + 1)
|
pairOrder.set(k, lane + 1)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -1216,6 +1314,16 @@ export default function NetworkMapPage() {
|
|||||||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
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
|
const connectedTunnels = selected
|
||||||
? mapGreTunnels.filter((t) => {
|
? mapGreTunnels.filter((t) => {
|
||||||
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||||||
@@ -1410,6 +1518,7 @@ export default function NetworkMapPage() {
|
|||||||
|
|
||||||
{/* ── GRE edges ── */}
|
{/* ── GRE edges ── */}
|
||||||
{greEdges.map((e, i) => {
|
{greEdges.map((e, i) => {
|
||||||
|
const edgeId = greEdgeKey(e)
|
||||||
const ts = TUNNEL_STYLE[e.tunnel.status]
|
const ts = TUNNEL_STYLE[e.tunnel.status]
|
||||||
const fromN = nodeById[e.fromServer.id]
|
const fromN = nodeById[e.fromServer.id]
|
||||||
const toN = nodeById[e.toServer.id]
|
const toN = nodeById[e.toServer.id]
|
||||||
@@ -1419,12 +1528,12 @@ export default function NetworkMapPage() {
|
|||||||
!isVisible(fromN) &&
|
!isVisible(fromN) &&
|
||||||
!isVisible(toN)
|
!isVisible(toN)
|
||||||
const baseProbe = greTunnelProbe(e.tunnel)
|
const baseProbe = greTunnelProbe(e.tunnel)
|
||||||
const spGre = speedProbeByTunnelId.get(e.tunnel.id)
|
const spGre = speedProbeByTunnelId.get(edgeId)
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||||
const rttFromMonitor = spGre?.lastPingRttMs != null
|
const rttFromMonitor = spGre?.lastPingRttMs != null
|
||||||
const throughputFromMonitor =
|
const throughputFromMonitor =
|
||||||
spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null
|
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 tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052))
|
||||||
const normalPx =
|
const normalPx =
|
||||||
(badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
|
(badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22)
|
||||||
@@ -1443,7 +1552,7 @@ export default function NetworkMapPage() {
|
|||||||
setSelWanIdx(null)
|
setSelWanIdx(null)
|
||||||
}
|
}
|
||||||
return (
|
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
|
<line
|
||||||
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
x1={e.from.x} y1={e.from.y} x2={e.to.x} y2={e.to.y}
|
||||||
stroke={ts.stroke} strokeWidth="1.5"
|
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}`} />
|
path={`M ${e.from.x} ${e.from.y} L ${e.to.x} ${e.to.y}`} />
|
||||||
</circle>
|
</circle>
|
||||||
)}
|
)}
|
||||||
{showPingBadges && (
|
{showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && (
|
||||||
<GreEdgeMetricBadge
|
<GreEdgeMetricBadge
|
||||||
mx={mx}
|
mx={mx}
|
||||||
my={my}
|
my={my}
|
||||||
@@ -1503,7 +1612,7 @@ export default function NetworkMapPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ── WAN→JH edges ── */}
|
{/* ── WAN→JH edges ── */}
|
||||||
{wanJhEdges.map((edge, i) => {
|
{visibleWanJhEdges.map((edge, i) => {
|
||||||
const jh = nodeById[edge.jhId]
|
const jh = nodeById[edge.jhId]
|
||||||
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
|
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
|
||||||
if (!jh || !satPos) return null
|
if (!jh || !satPos) return null
|
||||||
@@ -1528,10 +1637,35 @@ export default function NetworkMapPage() {
|
|||||||
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
|
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
|
||||||
</circle>
|
</circle>
|
||||||
)}
|
)}
|
||||||
{showPingBadges &&
|
{showPingBadges && (() => {
|
||||||
!suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && (
|
const kj = wanJhEdgeMapKey(edge)
|
||||||
<PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
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>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1677,7 +1811,7 @@ export default function NetworkMapPage() {
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
greEdges={greEdges}
|
greEdges={greEdges}
|
||||||
satPos={effectiveSatPos}
|
satPos={effectiveSatPos}
|
||||||
wanJhEdges={wanJhEdges}
|
wanJhEdges={visibleWanJhEdges}
|
||||||
homeRouters={homeRouters}
|
homeRouters={homeRouters}
|
||||||
onClose={() => setShowMinimap(false)}
|
onClose={() => setShowMinimap(false)}
|
||||||
onPan={(x, y) => setPan({ x, y })}
|
onPan={(x, y) => setPan({ x, y })}
|
||||||
@@ -1743,10 +1877,11 @@ export default function NetworkMapPage() {
|
|||||||
{(() => {
|
{(() => {
|
||||||
const edge = selectedGreEdge
|
const edge = selectedGreEdge
|
||||||
const t = edge.tunnel
|
const t = edge.tunnel
|
||||||
|
const selectedEdgeId = greEdgeKey(edge)
|
||||||
const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
|
const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap)
|
||||||
const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
|
const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap)
|
||||||
const baseProbe = greTunnelProbe(t)
|
const baseProbe = greTunnelProbe(t)
|
||||||
const spGr = speedProbeByTunnelId.get(t.id)
|
const spGr = speedProbeByTunnelId.get(selectedEdgeId)
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe)
|
||||||
const rttMon = spGr?.lastPingRttMs != null
|
const rttMon = spGr?.lastPingRttMs != null
|
||||||
const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null
|
const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null
|
||||||
@@ -2039,16 +2174,20 @@ export default function NetworkMapPage() {
|
|||||||
: mapServers.find((s) => s.id === t.serverId)
|
: mapServers.find((s) => s.id === t.serverId)
|
||||||
const fromServer = mapServers.find((s) => s.id === t.serverId)
|
const fromServer = mapServers.find((s) => s.id === t.serverId)
|
||||||
const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
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 baseProbe = greTunnelProbe(t)
|
||||||
const spGre =
|
const spGre =
|
||||||
fromServer && toServer ? speedProbeByTunnelId.get(t.id) : undefined
|
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
|
||||||
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
|
||||||
const pc = pingColor(merged.pingMs)
|
const pc = pingColor(merged.pingMs)
|
||||||
const showMetrics =
|
const showMetrics =
|
||||||
merged.pingMs != null ||
|
merged.pingMs != null ||
|
||||||
(merged.dlMbps != null && merged.ulMbps != null)
|
(merged.dlMbps != null && merged.ulMbps != null)
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-xs font-mono font-medium">{t.name}</span>
|
<span className="text-xs font-mono font-medium">{t.name}</span>
|
||||||
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
||||||
|
|||||||
+118
-9
@@ -12,6 +12,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
|
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -119,6 +120,28 @@ interface BackendOspfAll {
|
|||||||
bfdSessions: BackendBfdSession[]
|
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 ───────────────────────────────────────────────
|
// ─── backend → frontend mappers ───────────────────────────────────────────────
|
||||||
|
|
||||||
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
|
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
|
||||||
@@ -625,11 +648,25 @@ function NodeDetailPanel({
|
|||||||
|
|
||||||
// ─── interfaces tab ───────────────────────────────────────────────────────────
|
// ─── 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 [items, setItems] = useState<OspfItem[]>(initialItems)
|
||||||
const [dragging, setDragging] = useState<string | null>(null)
|
const [dragging, setDragging] = useState<string | null>(null)
|
||||||
const [dragOver, setDragOver] = useState<string | null>(null)
|
const [dragOver, setDragOver] = useState<string | null>(null)
|
||||||
const [toast, setToast] = 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
|
// Sync with live data when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -660,17 +697,67 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
|||||||
if (isLive) return {}
|
if (isLive) return {}
|
||||||
const out: Record<string, { optimalCost: number; prob: number }> = {}
|
const out: Record<string, { optimalCost: number; prob: number }> = {}
|
||||||
grouped.forEach(router => {
|
grouped.forEach(router => {
|
||||||
router.areas.forEach(ag => {
|
const ranked = router.areas
|
||||||
const ranked = [...ag.items]
|
.flatMap(ag => ag.items)
|
||||||
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
|
.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))
|
.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 } })
|
|
||||||
|
// В рамках одного роутера выдаём строго уникальные optimal cost.
|
||||||
|
ranked.forEach(({ item, prob }, idx) => {
|
||||||
|
out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}, [grouped, isLive])
|
}, [grouped, isLive])
|
||||||
|
|
||||||
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
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) {
|
function onDrop(routerKey: string, area: string, targetKey: string) {
|
||||||
if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return }
|
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" />Оптимизировать
|
<WandSparklesIcon className="size-4" />Оптимизировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canOptimizeLive && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
|
||||||
|
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
|
||||||
|
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!isLive && (
|
{!isLive && (
|
||||||
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
|
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
|
||||||
<SaveIcon className="size-4" />Сохранить
|
<SaveIcon className="size-4" />Сохранить
|
||||||
@@ -750,6 +843,8 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL
|
|||||||
{ag.items.map(item => {
|
{ag.items.map(item => {
|
||||||
const hint = hints[item.key]
|
const hint = hints[item.key]
|
||||||
const matches = hint && hint.optimalCost === item.cost
|
const matches = hint && hint.optimalCost === item.cost
|
||||||
|
const liveOptimal = liveOptimalCost[item.key] ?? uniqueLiveFallbackOpt[item.key] ?? item.cost
|
||||||
|
const costDiffers = liveOptimal !== item.cost
|
||||||
return (
|
return (
|
||||||
<div key={item.key}
|
<div key={item.key}
|
||||||
draggable={!isLive}
|
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"
|
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||||
}>opt {hint.optimalCost}</Chip>
|
}>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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1184,7 +1283,9 @@ export default function OspfPage() {
|
|||||||
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
|
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 neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
|
||||||
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
|
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
|
||||||
|
|
||||||
@@ -1389,7 +1490,15 @@ export default function OspfPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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" && (
|
{activeTab === "neighbors" && (
|
||||||
<NeighborsTab
|
<NeighborsTab
|
||||||
neighbors={displayNeighbors}
|
neighbors={displayNeighbors}
|
||||||
|
|||||||
@@ -529,15 +529,20 @@ export default function RecursiveRoutesPage() {
|
|||||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
|
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setLiveServerListReady(true)
|
||||||
setServers(mockServers)
|
setServers(mockServers)
|
||||||
setSelectedServerId(mockServers[0]?.id ?? "")
|
setSelectedServerId(mockServers[0]?.id ?? "")
|
||||||
setRows([])
|
setRows([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setLiveServerListReady(false)
|
||||||
apiFetch<BackendServer[]>("/api/servers")
|
apiFetch<BackendServer[]>("/api/servers")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const mapped = data.map(mapBackendServer)
|
const mapped = data.map(mapBackendServer)
|
||||||
@@ -548,10 +553,13 @@ export default function RecursiveRoutesPage() {
|
|||||||
setServers([])
|
setServers([])
|
||||||
setSelectedServerId("")
|
setSelectedServerId("")
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLiveServerListReady(true)
|
||||||
|
})
|
||||||
}, [isLive, apiFetch])
|
}, [isLive, apiFetch])
|
||||||
|
|
||||||
const loadRoutes = useCallback(async () => {
|
const loadRoutes = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
setBusy("load")
|
setBusy("load")
|
||||||
try {
|
try {
|
||||||
@@ -563,17 +571,17 @@ export default function RecursiveRoutesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||||
|
|
||||||
const loadGateways = useCallback(async () => {
|
const loadGateways = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
||||||
setGatewayOptions(res.gateways)
|
setGatewayOptions(res.gateways)
|
||||||
} catch {
|
} catch {
|
||||||
setGatewayOptions([])
|
setGatewayOptions([])
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ import {
|
|||||||
type OptimizerData,
|
type OptimizerData,
|
||||||
type OptimizerSettings,
|
type OptimizerSettings,
|
||||||
type OptimizerApiServer,
|
type OptimizerApiServer,
|
||||||
|
type RouteOptimizerSpeedProbe,
|
||||||
buildLiveOptimizerData,
|
buildLiveOptimizerData,
|
||||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||||
mapApiServersToTopology,
|
mapApiServersToTopology,
|
||||||
readStoredRouteOptimizerSettings,
|
readStoredRouteOptimizerSettings,
|
||||||
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
|
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
|
||||||
|
calcRouteScore,
|
||||||
} from "@/lib/route-optimizer-data"
|
} from "@/lib/route-optimizer-data"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
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))
|
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" {
|
function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" {
|
||||||
return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW"
|
return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW"
|
||||||
}
|
}
|
||||||
@@ -161,7 +156,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
|
|||||||
wanJhLegs.push({
|
wanJhLegs.push({
|
||||||
wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul,
|
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,
|
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 totalPing = hw.pingMs + je.pingMs
|
||||||
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
||||||
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
||||||
const score = calcScore(totalPing, dl, ul, pw)
|
const score = calcRouteScore(totalPing, dl, ul, pw)
|
||||||
fullRoutes.push({
|
fullRoutes.push({
|
||||||
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
||||||
homeId: home.id, wan, jh, exit: ex, hw, je,
|
homeId: home.id, wan, jh, exit: ex, hw, je,
|
||||||
@@ -521,7 +516,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{recs.map(r => {
|
{recs.map((r, idx) => {
|
||||||
const pinKey = `${homeId}::${r.community}`
|
const pinKey = `${homeId}::${r.community}`
|
||||||
const isPinned = pinned.has(pinKey)
|
const isPinned = pinned.has(pinKey)
|
||||||
const isApplied = applied.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
|
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={r.community} className={cn(
|
<tr key={`${homeId}::${r.community}::${idx}`} className={cn(
|
||||||
"hover:bg-muted/30 transition-colors",
|
"hover:bg-muted/30 transition-colors",
|
||||||
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
r.shouldSwitch && !isPinned && !isApplied && "bg-amber-500/5",
|
||||||
isApplied && "bg-emerald-500/5",
|
isApplied && "bg-emerald-500/5",
|
||||||
@@ -782,6 +777,7 @@ export default function RouteOptimizerPage() {
|
|||||||
|
|
||||||
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
|
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
|
||||||
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
|
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<OptimizerApiServer[]>([])
|
||||||
|
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
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 [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
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 load = useCallback(async (override?: OptimizerSettings) => {
|
||||||
const s = override ?? settingsRef.current
|
const s = override ?? settingsRef.current
|
||||||
@@ -803,9 +827,13 @@ export default function RouteOptimizerPage() {
|
|||||||
setData(buildMockData(s))
|
setData(buildMockData(s))
|
||||||
setLiveJumpHosts([])
|
setLiveJumpHosts([])
|
||||||
setLiveExitNodes([])
|
setLiveExitNodes([])
|
||||||
|
setLiveServers([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
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)
|
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
||||||
setLiveJumpHosts(jumpHosts)
|
setLiveJumpHosts(jumpHosts)
|
||||||
setLiveExitNodes(exitNodes)
|
setLiveExitNodes(exitNodes)
|
||||||
@@ -819,13 +847,21 @@ export default function RouteOptimizerPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
rulesets = null
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||||
setData(null)
|
setData(null)
|
||||||
setLiveJumpHosts([])
|
setLiveJumpHosts([])
|
||||||
setLiveExitNodes([])
|
setLiveExitNodes([])
|
||||||
|
setLiveServers([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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]) =>
|
const set = <K extends keyof OptimizerSettings>(k: K, v: OptimizerSettings[K]) =>
|
||||||
setSettings(prev => ({ ...prev, [k]: v }))
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -1081,6 +1212,136 @@ export default function RouteOptimizerPage() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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 ──────────────────────────────── */}
|
{/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
|||||||
@@ -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: <EyeIcon className="size-3" />, items: ["Домены", "IP-диапазоны", "ASN", "Communities"] },
|
||||||
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
|
{ group: "Управление", icon: <WrenchIcon className="size-3" />, items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] },
|
||||||
{ group: "Инструменты", icon: <ShieldIcon className="size-3" />, items: ["Оптимизатор маршрутов", "OSPF", "Диагностика 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)
|
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",
|
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]
|
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) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
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 [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null)
|
||||||
const [showEvoKey, setShowEvoKey] = useState(false)
|
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
|
// general
|
||||||
const [lang, setLang] = useState("ru")
|
const [lang, setLang] = useState("ru")
|
||||||
const [theme, setTheme] = useState("system")
|
const [theme, setTheme] = useState("system")
|
||||||
@@ -927,31 +904,6 @@ export default function SettingsPage() {
|
|||||||
// total sub-users count for summary
|
// total sub-users count for summary
|
||||||
const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0)
|
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(() => {
|
useEffect(() => {
|
||||||
if (section !== "EvoBGP") return
|
if (section !== "EvoBGP") return
|
||||||
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() })
|
||||||
@@ -1178,145 +1130,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</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 ──
|
// ── EvoBGP ──
|
||||||
if (section === "EvoBGP") return (
|
if (section === "EvoBGP") return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -290,8 +290,17 @@ const userTraffic: UserTraffic[] = [
|
|||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RANGES = ["5м", "15м", "1ч", "4ч", "24ч"] as const
|
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
||||||
type Range = typeof RANGES[number]
|
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 GroupMode = "servers" | "users" | "gre"
|
||||||
type SortField = "rx" | "tx" | "name" | "sessions"
|
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-start justify-between mb-3 gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
|
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
|
||||||
<div className="flex gap-1 shrink-0">
|
<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"}
|
<Button key={r} size="sm" variant={r === range ? "default" : "ghost"}
|
||||||
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
|
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
|
||||||
{r}
|
{TRAFFIC_RANGE_LABELS[r]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -772,7 +781,7 @@ export default function TrafficPage() {
|
|||||||
const [sortField, setSortField] = useState<SortField>("rx")
|
const [sortField, setSortField] = useState<SortField>("rx")
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("desc")
|
const [sortDir, setSortDir] = useState<SortDir>("desc")
|
||||||
const [selectedId, setSelectedId] = useState("srv1")
|
const [selectedId, setSelectedId] = useState("srv1")
|
||||||
const [range, setRange] = useState<Range>("1ч")
|
const [range, setRange] = useState<Range>("1h")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||||||
const [liveBusy, setLiveBusy] = useState(false)
|
const [liveBusy, setLiveBusy] = useState(false)
|
||||||
|
|||||||
+461
-166
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
import { Sparkline } from "@/components/sparkline"
|
import { Sparkline } from "@/components/sparkline"
|
||||||
|
import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data"
|
||||||
import type { PingProbe } from "@/lib/data"
|
import type { PingProbe } from "@/lib/data"
|
||||||
@@ -29,6 +30,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
|
||||||
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
|
/** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */
|
||||||
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
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 {
|
function rttColor(rtt: number | null, loss: number): string {
|
||||||
if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]"
|
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)]"
|
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)"
|
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 ──────────────────────────────────────────────────────────
|
// ── shared components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
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="text-muted-foreground/30">•</span>
|
||||||
<span className="truncate">{server.host}</span>
|
<span className="truncate">{server.host}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!server.enabled && (
|
||||||
|
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400 leading-snug">
|
||||||
|
В инвентаре выключен — для ping всё равно можно выбрать, если бекенд достигает REST API.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -553,6 +689,8 @@ function LinkedFilterPickerCards({
|
|||||||
|
|
||||||
interface ServerResource {
|
interface ServerResource {
|
||||||
serverId: string
|
serverId: string
|
||||||
|
/** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */
|
||||||
|
hasData?: boolean
|
||||||
cpu: number
|
cpu: number
|
||||||
cpuHistory: number[]
|
cpuHistory: number[]
|
||||||
ramUsed: number // MB
|
ramUsed: number // MB
|
||||||
@@ -577,6 +715,24 @@ interface BackendServer {
|
|||||||
os: string | null
|
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 {
|
interface SpeedTestRun {
|
||||||
id: string
|
id: string
|
||||||
startedAt: number
|
startedAt: number
|
||||||
@@ -620,6 +776,25 @@ interface SpeedProbeRow {
|
|||||||
lastPingError?: string | null
|
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 {
|
function stripIpCidr(addr: string): string {
|
||||||
const t = addr.trim()
|
const t = addr.trim()
|
||||||
if (!t) return ""
|
if (!t) return ""
|
||||||
@@ -663,7 +838,7 @@ function findLinkedSpeedProbe(
|
|||||||
|
|
||||||
function fmtMB(mb: number): string {
|
function fmtMB(mb: number): string {
|
||||||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||||||
return `${mb} МБ`
|
return `${mb.toFixed(1)} МБ`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtUptime(sec: number): string {
|
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,
|
uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60,
|
||||||
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
|
boardName: BOARD_MAP[s.type] ?? "RouterBOARD",
|
||||||
temp: s.type !== "home-router" ? 34 + (h % 32) : undefined,
|
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"
|
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 [sortKey, setSortKey] = useState<ResSortKey>("name")
|
||||||
const [sortAsc, setSortAsc] = useState(true)
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
const [resSearch, setResSearch] = useState("")
|
const [resSearch, setResSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<ResTypeFilter>("all")
|
||||||
|
|
||||||
const rows = useMemo(() => resources.map(r => ({
|
const rows = useMemo(() => resources.map((r) => {
|
||||||
...r,
|
const hasData = r.hasData !== false
|
||||||
server: serversList.find(s => s.id === r.serverId),
|
const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0
|
||||||
ramPct: Math.round(r.ramUsed / r.ramTotal * 100),
|
const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0
|
||||||
hddPct: Math.round(r.hddUsed / r.hddTotal * 100),
|
return {
|
||||||
})).filter(r => r.server !== undefined), [resources, serversList])
|
...r,
|
||||||
|
hasData,
|
||||||
|
server: serversList.find(s => s.id === r.serverId),
|
||||||
|
ramPct,
|
||||||
|
hddPct,
|
||||||
|
}
|
||||||
|
}).filter(r => r.server !== undefined), [resources, serversList])
|
||||||
|
|
||||||
// KPI aggregates
|
// KPI aggregates (только серверы с реальными сэмплами за окно)
|
||||||
const online = rows.filter(r => r.server!.status === "online")
|
const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData)
|
||||||
const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0
|
const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0
|
||||||
const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.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.cpu >= 85).length
|
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.ramPct >= 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.hddPct >= 85).length
|
const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
const alerts = useMemo(() =>
|
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],
|
[rows],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -998,7 +1180,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
{visible.map(r => {
|
{visible.map(r => {
|
||||||
const srv = r.server!
|
const srv = r.server!
|
||||||
const offline = srv.status !== "online"
|
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%)"
|
const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||||
return (
|
return (
|
||||||
<tr key={r.serverId} className={cn(
|
<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>
|
<span className="font-mono font-semibold">{srv.name}</span>
|
||||||
<TypeChip type={srv.type} />
|
<TypeChip type={srv.type} />
|
||||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Board + ROS */}
|
{/* Board + ROS */}
|
||||||
<td className="px-4 py-3 hidden md:table-cell">
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
<div className="flex flex-col leading-tight">
|
<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>
|
<span className="text-[10px] text-muted-foreground/50">{srv.os}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{offline
|
{noMetrics
|
||||||
? <span className="text-xs text-muted-foreground/30">—</span>
|
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1046,8 +1235,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
|
|
||||||
{/* RAM */}
|
{/* RAM */}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{offline
|
{noMetrics
|
||||||
? <span className="text-xs text-muted-foreground/30">—</span>
|
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
@@ -1063,8 +1252,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
|
|
||||||
{/* HDD */}
|
{/* HDD */}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{offline
|
{noMetrics
|
||||||
? <span className="text-xs text-muted-foreground/30">—</span>
|
? <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
@@ -1081,13 +1270,13 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
{/* Uptime */}
|
{/* Uptime */}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
{offline ? "—" : fmtUptime(r.uptimeSeconds)}
|
{noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Temp */}
|
{/* Temp */}
|
||||||
<td className="px-4 py-3">
|
<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",
|
<span className={cn("font-mono text-sm font-semibold tabular-nums",
|
||||||
r.temp >= 70 ? "text-red-600 dark:text-red-400"
|
r.temp >= 70 ? "text-red-600 dark:text-red-400"
|
||||||
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
|
: r.temp >= 55 ? "text-amber-600 dark:text-amber-400"
|
||||||
@@ -1108,7 +1297,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground/40 text-center">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1118,8 +1309,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];
|
|||||||
|
|
||||||
export default function UptimePage() {
|
export default function UptimePage() {
|
||||||
const [allServers, setAllServers] = useState<Server[]>(mockServers)
|
const [allServers, setAllServers] = useState<Server[]>(mockServers)
|
||||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||||
const isLive = mode === "live" && backendStatus === true
|
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||||||
|
const liveApi = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
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 [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
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 [speedBusy, setSpeedBusy] = useState(false)
|
||||||
const [speedError, setSpeedError] = useState<string | null>(null)
|
const [speedError, setSpeedError] = useState<string | null>(null)
|
||||||
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
|
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
|
||||||
@@ -1153,37 +1350,18 @@ export default function UptimePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!liveApi) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setAllServers(mockServers)
|
setAllServers(mockServers)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiFetch<BackendServer[]>("/api/servers")
|
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
|
||||||
.then((data) => {
|
}, [liveApi])
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive) return
|
if (liveApi) return
|
||||||
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
|
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
|
||||||
}, [isLive])
|
}, [liveApi])
|
||||||
|
|
||||||
const isPausedRef = useRef(isPaused)
|
const isPausedRef = useRef(isPaused)
|
||||||
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
|
useEffect(() => { isPausedRef.current = isPaused }, [isPaused])
|
||||||
@@ -1192,82 +1370,137 @@ export default function UptimePage() {
|
|||||||
const overviewReqRef = useRef(0)
|
const overviewReqRef = useRef(0)
|
||||||
|
|
||||||
const loadLiveOverview = useCallback(async () => {
|
const loadLiveOverview = useCallback(async () => {
|
||||||
if (!isLive) return
|
if (!liveApi) return
|
||||||
const myReq = ++overviewReqRef.current
|
const myReq = ++overviewReqRef.current
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
try {
|
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
|
if (myReq !== overviewReqRef.current) return
|
||||||
|
setAllServers(mapBackendServersToServers(serverRows))
|
||||||
setProbes(data.probes)
|
setProbes(data.probes)
|
||||||
setResources(data.resources)
|
setResources(data.resources)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (myReq !== overviewReqRef.current) return
|
if (myReq !== overviewReqRef.current) return
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!liveApi) return
|
||||||
queueMicrotask(() => { void loadLiveOverview() })
|
queueMicrotask(() => { void refreshUptimeLive() })
|
||||||
}, [isLive, loadLiveOverview])
|
}, [liveApi, refreshUptimeLive])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!liveApi || isPaused) return
|
||||||
void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
|
const id = setInterval(() => { void refreshUptimeLive() }, 5_000)
|
||||||
.then((data) => setSpeedProbes(data.probes ?? []))
|
return () => clearInterval(id)
|
||||||
.catch(() => setSpeedProbes([]))
|
}, [liveApi, isPaused, refreshUptimeLive])
|
||||||
}, [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])
|
|
||||||
|
|
||||||
// add-probe form
|
// add-probe form
|
||||||
const [newSrcId, setNewSrcId] = useState("")
|
const [newSrcId, setNewSrcId] = useState("")
|
||||||
@@ -1279,13 +1512,11 @@ export default function UptimePage() {
|
|||||||
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
|
const [srcInterfaces, setSrcInterfaces] = useState<Array<{ name: string; running: boolean; disabled: boolean }>>([])
|
||||||
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
|
const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false)
|
||||||
|
|
||||||
const selectableSources = useMemo(
|
/** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */
|
||||||
() => allServers.filter(s => s.enabled),
|
const selectableSources = useMemo(() => allServers, [allServers])
|
||||||
[allServers],
|
|
||||||
)
|
|
||||||
|
|
||||||
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
|
const loadSpeedInterfaces = useCallback(async (serverId: string) => {
|
||||||
if (!isLive) return
|
if (!liveApi) return
|
||||||
if (!serverId || speedIfaces[serverId]) return
|
if (!serverId || speedIfaces[serverId]) return
|
||||||
const id = Number.parseInt(serverId, 10)
|
const id = Number.parseInt(serverId, 10)
|
||||||
if (!Number.isFinite(id)) return
|
if (!Number.isFinite(id)) return
|
||||||
@@ -1295,7 +1526,7 @@ export default function UptimePage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
|
setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] }))
|
||||||
}
|
}
|
||||||
}, [apiFetch, isLive, speedIfaces])
|
}, [apiFetch, liveApi, speedIfaces])
|
||||||
|
|
||||||
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
|
/** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1333,7 +1564,7 @@ export default function UptimePage() {
|
|||||||
setNewSrcInterface("")
|
setNewSrcInterface("")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isLive) {
|
if (!liveApi) {
|
||||||
setSrcInterfaces([])
|
setSrcInterfaces([])
|
||||||
setNewSrcInterface("")
|
setNewSrcInterface("")
|
||||||
return
|
return
|
||||||
@@ -1356,22 +1587,23 @@ export default function UptimePage() {
|
|||||||
setNewSrcInterface("")
|
setNewSrcInterface("")
|
||||||
})
|
})
|
||||||
.finally(() => setSrcInterfacesBusy(false))
|
.finally(() => setSrcInterfacesBusy(false))
|
||||||
}, [sheetOpen, newSrcId, isLive, apiFetch])
|
}, [sheetOpen, newSrcId, liveApi, apiFetch])
|
||||||
|
|
||||||
// servers that have at least one probe (preserve data-order)
|
// servers that have at least one probe (preserve data-order)
|
||||||
const probedServerIds = useMemo(
|
const probedServerIds = useMemo(
|
||||||
() => [...new Set(probes.map(p => p.srcServerId))],
|
() => [...new Set(probes.map(p => p.srcServerId))],
|
||||||
[probes],
|
[probes],
|
||||||
)
|
)
|
||||||
const probedServers = useMemo(
|
const probedServers = useMemo(() => {
|
||||||
() => allServers.filter(s => probedServerIds.includes(s.id)),
|
const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id))
|
||||||
[probedServerIds, allServers],
|
const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id))
|
||||||
)
|
return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)]
|
||||||
|
}, [probedServerIds, allServers])
|
||||||
|
|
||||||
// live RTT tick
|
// live RTT tick
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
if (isLive) return
|
if (liveApi) return
|
||||||
if (isPausedRef.current) return
|
if (isPausedRef.current) return
|
||||||
setProbes(prev => prev.map(p => {
|
setProbes(prev => prev.map(p => {
|
||||||
if (!p.enabled || p.status === "down" || p.rtt === null) return p
|
if (!p.enabled || p.status === "down" || p.rtt === null) return p
|
||||||
@@ -1381,16 +1613,17 @@ export default function UptimePage() {
|
|||||||
}))
|
}))
|
||||||
}, 3000)
|
}, 3000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [isLive])
|
}, [liveApi])
|
||||||
|
|
||||||
// live resource tick
|
// live resource tick
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
if (isLive) return
|
if (liveApi) return
|
||||||
if (isPausedRef.current) return
|
if (isPausedRef.current) return
|
||||||
setResources(prev => prev.map(r => {
|
setResources(prev => prev.map(r => {
|
||||||
const srv = allServers.find(s => s.id === r.serverId)
|
const srv = allServers.find(s => s.id === r.serverId)
|
||||||
if (!srv || srv.status !== "online") return r
|
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 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 newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128)))
|
||||||
const newTemp = r.temp !== undefined
|
const newTemp = r.temp !== undefined
|
||||||
@@ -1407,7 +1640,7 @@ export default function UptimePage() {
|
|||||||
}))
|
}))
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [isLive, allServers])
|
}, [liveApi, allServers])
|
||||||
|
|
||||||
// ── derived ──
|
// ── derived ──
|
||||||
const stats = useMemo(() => ({
|
const stats = useMemo(() => ({
|
||||||
@@ -1420,7 +1653,7 @@ export default function UptimePage() {
|
|||||||
const alertCount = useMemo(() =>
|
const alertCount = useMemo(() =>
|
||||||
resources.filter(r => {
|
resources.filter(r => {
|
||||||
const s = allServers.find(x => x.id === r.serverId)
|
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 ramPct = Math.round(r.ramUsed / r.ramTotal * 100)
|
||||||
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
|
const hddPct = Math.round(r.hddUsed / r.hddTotal * 100)
|
||||||
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
|
return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70
|
||||||
@@ -1467,16 +1700,19 @@ export default function UptimePage() {
|
|||||||
arr.push(p)
|
arr.push(p)
|
||||||
map.set(p.srcServerId, arr)
|
map.set(p.srcServerId, arr)
|
||||||
}
|
}
|
||||||
return selectableSources
|
const ids = [...map.keys()].sort((a, b) => {
|
||||||
.filter((s) => map.has(s.id))
|
const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a)
|
||||||
.map((s) => ({
|
const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b)
|
||||||
server: s,
|
return (sa.host + sa.name).localeCompare(sb.host + sb.name)
|
||||||
probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)),
|
})
|
||||||
}))
|
return ids.map((id) => ({
|
||||||
}, [speedProbes, selectableSources])
|
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[]) => {
|
const persistProbes = useCallback((rows: PingProbe[]) => {
|
||||||
if (!isLive) return
|
if (!liveApi) return
|
||||||
void apiFetch("/api/uptime/probes", {
|
void apiFetch("/api/uptime/probes", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1492,17 +1728,17 @@ export default function UptimePage() {
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [apiFetch, isLive])
|
}, [apiFetch, liveApi])
|
||||||
|
|
||||||
const toggleDashboardStar = useCallback((id: string) => {
|
const toggleDashboardStar = useCallback((id: string) => {
|
||||||
const cur = probes.find((p) => p.id === id)
|
const cur = probes.find((p) => p.id === id)
|
||||||
if (!cur) return
|
if (!cur) return
|
||||||
const nextVal = !cur.showOnDashboard
|
const nextVal = !cur.showOnDashboard
|
||||||
if (isLive) {
|
if (liveApi) {
|
||||||
overviewReqRef.current += 1
|
overviewReqRef.current += 1
|
||||||
}
|
}
|
||||||
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
|
setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p)))
|
||||||
if (isLive) {
|
if (liveApi) {
|
||||||
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
|
void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -1526,10 +1762,10 @@ export default function UptimePage() {
|
|||||||
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
|
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [probes, isLive, apiFetch, loadLiveOverview])
|
}, [probes, liveApi, apiFetch, loadLiveOverview])
|
||||||
|
|
||||||
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
|
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
|
||||||
if (!isLive) return
|
if (!liveApi) return
|
||||||
void apiFetch("/api/uptime/speed-probes", {
|
void apiFetch("/api/uptime/speed-probes", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -1551,7 +1787,7 @@ export default function UptimePage() {
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [apiFetch, isLive])
|
}, [apiFetch, liveApi])
|
||||||
|
|
||||||
// ── actions ──
|
// ── actions ──
|
||||||
const toggleProbe = (id: string, v: boolean) =>
|
const toggleProbe = (id: string, v: boolean) =>
|
||||||
@@ -1562,7 +1798,7 @@ export default function UptimePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deleteProbe = (id: string) => {
|
const deleteProbe = (id: string) => {
|
||||||
if (!isLive) {
|
if (!liveApi) {
|
||||||
const s = readMockDashboardStarIds()
|
const s = readMockDashboardStarIds()
|
||||||
s.delete(id)
|
s.delete(id)
|
||||||
writeMockDashboardStarIds(s)
|
writeMockDashboardStarIds(s)
|
||||||
@@ -1747,7 +1983,7 @@ export default function UptimePage() {
|
|||||||
lines: ["status: running..."],
|
lines: ["status: running..."],
|
||||||
}, ...prev].slice(0, 20))
|
}, ...prev].slice(0, 20))
|
||||||
try {
|
try {
|
||||||
if (isLive) {
|
if (liveApi) {
|
||||||
const payload = {
|
const payload = {
|
||||||
runId,
|
runId,
|
||||||
probeId: probe.id,
|
probeId: probe.id,
|
||||||
@@ -1974,15 +2210,15 @@ export default function UptimePage() {
|
|||||||
: <><PauseIcon className="size-4" />Пауза</>}
|
: <><PauseIcon className="size-4" />Пауза</>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
|
||||||
if (isLive) {
|
if (liveApi) {
|
||||||
void loadLiveOverview()
|
void refreshUptimeLive({ showSpinner: true, pollDevices: tab === "resources" || tab === "probes" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
||||||
setResources(INIT_RESOURCES)
|
setResources(INIT_RESOURCES)
|
||||||
}}>
|
}}>
|
||||||
<RefreshCwIcon className="size-4" />{isLive ? "Обновить" : "Сбросить"}
|
<RefreshCwIcon className={cn("size-4", uptimeRefreshBusy && "animate-spin")} />{liveApi ? "Обновить" : "Сбросить"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{tab === "probes" && (
|
{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]"
|
<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)" }}>
|
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
|
||||||
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
|
<span className="size-1.5 rounded-full bg-[var(--status-online)] animate-pulse" />
|
||||||
{isLive
|
{liveApi
|
||||||
? "Live (backend)"
|
? (backendStatus === false
|
||||||
: "Live · проба обновляется каждые 3с · ресурсы каждые 5с"}
|
? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются"
|
||||||
|
: "Live (backend)")
|
||||||
|
: "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
@@ -2066,7 +2304,7 @@ export default function UptimePage() {
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* ── resources tab ── */}
|
{/* ── resources tab ── */}
|
||||||
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} />}
|
{tab === "resources" && <ResourcesTab resources={resources} serversList={allServers} liveApi={liveApi} />}
|
||||||
|
|
||||||
{/* ── speed tab ── */}
|
{/* ── speed tab ── */}
|
||||||
{tab === "speed" && (
|
{tab === "speed" && (
|
||||||
@@ -2552,13 +2790,30 @@ export default function UptimePage() {
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<div className="divide-y divide-border/60">
|
<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 key={`${group.name}|${group.target}`}>
|
||||||
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
|
<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" />
|
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
|
||||||
<span className="font-medium truncate">{group.name}</span>
|
<span className="font-medium truncate min-w-0">{group.name}</span>
|
||||||
<span className="font-mono text-muted-foreground">{group.target}</span>
|
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
|
||||||
<span className="ml-auto text-muted-foreground">{group.probes.length} интерф.</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>
|
||||||
|
|
||||||
<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"
|
<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) => {
|
{group.probes.map((p) => {
|
||||||
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
|
const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces)
|
||||||
return (
|
return (
|
||||||
<div key={p.id}
|
<div key={p.id} className="border-b border-border/40 last:border-b-0">
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||||
!p.enabled && "opacity-40",
|
!p.enabled && "opacity-40",
|
||||||
@@ -2675,6 +2931,41 @@ export default function UptimePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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})
|
Добавить интерфейс для {group.name} ({group.target})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -2894,9 +3186,12 @@ export default function UptimePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Источник (кто пингует)">
|
<Field
|
||||||
|
label="Источник (кто пингует)"
|
||||||
|
hint="— весь каталог, в т.ч. выключенные в инвентаре (Home Router часто «выкл.», но доступен по LAN для ping)"
|
||||||
|
>
|
||||||
<ServerPickerCards
|
<ServerPickerCards
|
||||||
options={allServers.filter((s) => s.enabled)}
|
options={selectableSources}
|
||||||
selectedId={newSrcId}
|
selectedId={newSrcId}
|
||||||
onSelect={(id) => setNewSrcId(id)}
|
onSelect={(id) => setNewSrcId(id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"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",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Порог среднего RTT (мс) для ICMP-проб: выше — статус `warn` при потерях ≤1%.
|
||||||
|
* 60 мс было слишком жёстко для WAN/CDN (например ~65–80 мс до 1.1.1.1 из РФ — нормальная география).
|
||||||
|
* Фронт: держите то же значение в `lib/ping-probe.ts`.
|
||||||
|
*/
|
||||||
|
export const PING_PROBE_WARN_RTT_MS = 120
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import Database from "better-sqlite3"
|
import Database from "better-sqlite3"
|
||||||
|
|
||||||
|
type SqliteHandle = InstanceType<typeof Database>
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
import { drizzle } from "drizzle-orm/better-sqlite3"
|
||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
import * as schema from "./schema.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
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
|
||||||
ON 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 (
|
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
base_url TEXT NOT NULL DEFAULT '',
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
@@ -197,6 +253,120 @@ CREATE TABLE IF NOT EXISTS evobgp_settings (
|
|||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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
|
// Lightweight schema evolution for existing databases without migrations
|
||||||
@@ -215,6 +385,10 @@ const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on
|
|||||||
if (!hasShowOnDashboardColumn) {
|
if (!hasShowOnDashboardColumn) {
|
||||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
|
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 uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
|
||||||
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
|
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`)
|
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 uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
|
||||||
const ensureSpeedProbeCol = (name: string, ddl: string) => {
|
const ensureSpeedProbeCol = (name: string, ddl: string) => {
|
||||||
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
|
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_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`)
|
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 }>
|
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
|
||||||
if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
||||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
|
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 '[]'`)
|
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(`
|
sqlite.exec(`
|
||||||
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
||||||
SELECT 1, 1, 30, 14
|
SELECT 1, 1, 30, 14
|
||||||
@@ -266,4 +493,25 @@ SELECT 1, '', '', 0
|
|||||||
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
|
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 })
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
|
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
||||||
|
export const sqliteDatabase: SqliteHandle = sqlite
|
||||||
|
|||||||
@@ -115,6 +115,46 @@ export const trafficSettings = sqliteTable("traffic_settings", {
|
|||||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
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) ──────────────────────
|
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
|
||||||
|
|
||||||
export const trafficSamples = sqliteTable("traffic_samples", {
|
export const trafficSamples = sqliteTable("traffic_samples", {
|
||||||
@@ -136,7 +176,11 @@ export const trafficSamples = sqliteTable("traffic_samples", {
|
|||||||
|
|
||||||
export const uptimeSettings = sqliteTable("uptime_settings", {
|
export const uptimeSettings = sqliteTable("uptime_settings", {
|
||||||
id: integer("id").primaryKey(),
|
id: integer("id").primaryKey(),
|
||||||
|
/** Устаревший агрегат: синхронизируется как resources ∨ ping ∨ speed (для совместимости). */
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
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),
|
intervalSec: integer("interval_sec").notNull().default(15),
|
||||||
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
|
probeIntervalSec: integer("probe_interval_sec").notNull().default(15),
|
||||||
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
|
speedIntervalSec: integer("speed_interval_sec").notNull().default(60),
|
||||||
@@ -158,6 +202,8 @@ export const uptimeProbes = sqliteTable("uptime_probes", {
|
|||||||
target: text("target").notNull(),
|
target: text("target").notNull(),
|
||||||
probeFilter: text("probe_filter").notNull().default("—"),
|
probeFilter: text("probe_filter").notNull().default("—"),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
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),
|
showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
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'))`),
|
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", {
|
export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
probeId: text("probe_id"),
|
probeId: text("probe_id"),
|
||||||
@@ -264,6 +459,7 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert
|
|||||||
export type FilterRuleRow = typeof filterRules.$inferSelect
|
export type FilterRuleRow = typeof filterRules.$inferSelect
|
||||||
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
|
||||||
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
|
||||||
|
export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect
|
||||||
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
|
||||||
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
|
||||||
export type UptimeProbeRow = typeof uptimeProbes.$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 UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
|
||||||
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect
|
||||||
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect
|
||||||
|
export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||||
export type EvobgpSettingsRow = typeof evobgpSettings.$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
@@ -9,12 +9,15 @@ import execRoutes from "./routes/exec.js"
|
|||||||
import filtersRoutes from "./routes/filters.js"
|
import filtersRoutes from "./routes/filters.js"
|
||||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||||
import trafficRoutes from "./routes/traffic.js"
|
import trafficRoutes from "./routes/traffic.js"
|
||||||
|
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||||
import uptimeRoutes from "./routes/uptime.js"
|
import uptimeRoutes from "./routes/uptime.js"
|
||||||
import networkRoutes from "./routes/network.js"
|
import networkRoutes from "./routes/network.js"
|
||||||
import evobgpRoutes from "./routes/evobgp.js"
|
import evobgpRoutes from "./routes/evobgp.js"
|
||||||
import probesRoutes from "./routes/probes.js"
|
import probesRoutes from "./routes/probes.js"
|
||||||
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
|
import schedulerRoutes from "./routes/scheduler.js"
|
||||||
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
|
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||||
|
import alertsRoutes from "./routes/alerts.js"
|
||||||
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
|
||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
// ── app factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,18 +52,18 @@ await app.register(execRoutes, { prefix: "/api" })
|
|||||||
await app.register(filtersRoutes, { prefix: "/api" })
|
await app.register(filtersRoutes, { prefix: "/api" })
|
||||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||||
await app.register(trafficRoutes, { prefix: "/api" })
|
await app.register(trafficRoutes, { prefix: "/api" })
|
||||||
|
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||||
await app.register(probesRoutes, { 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()
|
refreshScheduler()
|
||||||
void collectTrafficOnce()
|
|
||||||
restartUptimeCollector()
|
|
||||||
void collectUptimeOnce()
|
|
||||||
app.addHook("onClose", async () => {
|
app.addHook("onClose", async () => {
|
||||||
stopTrafficCollector()
|
stopScheduler()
|
||||||
stopUptimeCollector()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── start ──────────────────────────────────────────────────────────────────────
|
// ── start ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import {
|
||||||
|
getAlertsMeta,
|
||||||
|
getTelegramBotToken,
|
||||||
|
getTelegramMessageThreadIdForApi,
|
||||||
|
getTelegramPublic,
|
||||||
|
listAlertGroups,
|
||||||
|
listAlertRules,
|
||||||
|
listMergedHistory,
|
||||||
|
replaceAlertsConfig,
|
||||||
|
formatAlertRuleTestTelegramText,
|
||||||
|
sendTelegramAlertMessage,
|
||||||
|
updateTelegramSettings,
|
||||||
|
} from "../services/alerts-service.js"
|
||||||
|
|
||||||
|
const AlertTypeSchema = z.enum([
|
||||||
|
"gre-tunnel",
|
||||||
|
"bgp-peer",
|
||||||
|
"bgp-prefix",
|
||||||
|
"gre-client",
|
||||||
|
"server",
|
||||||
|
"rtt",
|
||||||
|
"loss",
|
||||||
|
"traffic",
|
||||||
|
])
|
||||||
|
const AlertSeveritySchema = z.enum(["critical", "warning", "info"])
|
||||||
|
const AlertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"])
|
||||||
|
const RecoveryModeSchema = z.enum(["always", "never", "conditional"])
|
||||||
|
|
||||||
|
const AlertGroupBodySchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
combineMode: z.enum(["any", "all"]),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
cooldownOverride: z.union([AlertCooldownSchema, z.null()]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AlertRuleBodySchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: AlertTypeSchema,
|
||||||
|
target: z.string().optional(),
|
||||||
|
targets: z.array(z.string().min(1)).optional(),
|
||||||
|
groupId: z.string().nullable().optional(),
|
||||||
|
/** Сводка; если переданы `conditions`, может дублировать первую строку */
|
||||||
|
condition: z.string().optional(),
|
||||||
|
/** Несколько условий (OR); приоритет над одной строкой `condition` */
|
||||||
|
conditions: z.array(z.string().min(1)).optional(),
|
||||||
|
severity: AlertSeveritySchema,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
cooldown: AlertCooldownSchema,
|
||||||
|
/** Секунды подтверждения стабильности; 0 / null / не передано — выкл. */
|
||||||
|
confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
|
||||||
|
recoveryMode: RecoveryModeSchema.optional(),
|
||||||
|
recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
|
||||||
|
chatId: z.string(),
|
||||||
|
})
|
||||||
|
.refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), {
|
||||||
|
message: "Нужен хотя бы один объект: targets или target",
|
||||||
|
path: ["targets"],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(r) =>
|
||||||
|
(r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()),
|
||||||
|
{ message: "Нужно хотя бы одно условие: conditions или condition", path: ["conditions"] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const PutRulesSchema = z.object({
|
||||||
|
rules: z.array(AlertRuleBodySchema),
|
||||||
|
groups: z.array(AlertGroupBodySchema).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PutTelegramSchema = z.object({
|
||||||
|
/** undefined — не менять; null или "" — очистить токен */
|
||||||
|
token: z.union([z.string(), z.null()]).optional(),
|
||||||
|
chatId: z.string().optional(),
|
||||||
|
/** undefined — не менять; null — сбросить (общий чат супергруппы) */
|
||||||
|
messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RulePreviewTelegramSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
targets: z.array(z.string().min(1)).min(1),
|
||||||
|
conditionLine: z.string().min(1),
|
||||||
|
severity: AlertSeveritySchema,
|
||||||
|
cooldown: AlertCooldownSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
const TestTelegramSchema = z.object({
|
||||||
|
/** Если не передан — взять сохранённый токен из БД */
|
||||||
|
token: z.string().optional(),
|
||||||
|
chatId: z.string().optional(),
|
||||||
|
/** Если не передан — из БД; для проверки черновика до сохранения */
|
||||||
|
messageThreadId: z.coerce.number().int().positive().optional(),
|
||||||
|
/** Если задан — вместо короткого текста отправляется предпросмотр правила (создание / правка). */
|
||||||
|
rulePreview: RulePreviewTelegramSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/alerts", async (_req, reply) => {
|
||||||
|
const tg = getTelegramPublic()
|
||||||
|
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
|
||||||
|
return reply.send({
|
||||||
|
telegram: { ...tg, connected },
|
||||||
|
groups: listAlertGroups(),
|
||||||
|
rules: listAlertRules(),
|
||||||
|
history: listMergedHistory(),
|
||||||
|
meta: getAlertsMeta(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/alerts/rules", async (req, reply) => {
|
||||||
|
const parsed = PutRulesSchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const groups =
|
||||||
|
parsed.data.groups ??
|
||||||
|
listAlertGroups().map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
combineMode: g.combineMode,
|
||||||
|
enabled: g.enabled,
|
||||||
|
cooldownOverride: g.cooldownOverride,
|
||||||
|
}))
|
||||||
|
const rules = parsed.data.rules.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
target: r.target,
|
||||||
|
targets: r.targets,
|
||||||
|
groupId: r.groupId ?? null,
|
||||||
|
condition: r.condition,
|
||||||
|
conditions:
|
||||||
|
r.conditions && r.conditions.length > 0
|
||||||
|
? r.conditions.map((c) => c.trim()).filter(Boolean)
|
||||||
|
: r.condition?.trim()
|
||||||
|
? [r.condition.trim()]
|
||||||
|
: [],
|
||||||
|
severity: r.severity,
|
||||||
|
enabled: r.enabled,
|
||||||
|
cooldown: r.cooldown,
|
||||||
|
confirmStabilitySec: r.confirmStabilitySec ?? null,
|
||||||
|
recoveryMode: r.recoveryMode ?? "always",
|
||||||
|
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
||||||
|
chatId: r.chatId,
|
||||||
|
}))
|
||||||
|
replaceAlertsConfig({ groups, rules })
|
||||||
|
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/alerts/telegram", async (req, reply) => {
|
||||||
|
const parsed = PutTelegramSchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const next = updateTelegramSettings({
|
||||||
|
token: parsed.data.token,
|
||||||
|
chatId: parsed.data.chatId,
|
||||||
|
messageThreadId: parsed.data.messageThreadId,
|
||||||
|
})
|
||||||
|
const connected = next.tokenConfigured && Boolean(next.chatId?.trim())
|
||||||
|
return reply.send({ ok: true, telegram: { ...next, connected } })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/alerts/telegram/test", async (req, reply) => {
|
||||||
|
const parsed = TestTelegramSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const fromDb = getTelegramBotToken()
|
||||||
|
const token = (parsed.data.token?.trim() || fromDb).trim()
|
||||||
|
const pub = getTelegramPublic()
|
||||||
|
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
|
||||||
|
if (!token) {
|
||||||
|
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
|
||||||
|
}
|
||||||
|
if (!chatId) {
|
||||||
|
return reply.status(400).send({ error: "Не задан Chat ID" })
|
||||||
|
}
|
||||||
|
const threadId =
|
||||||
|
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
|
||||||
|
? parsed.data.messageThreadId
|
||||||
|
: getTelegramMessageThreadIdForApi()
|
||||||
|
const text = parsed.data.rulePreview
|
||||||
|
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
|
||||||
|
: "MikroTik Manager — тест оповещений (Telegram)."
|
||||||
|
const send = await sendTelegramAlertMessage({
|
||||||
|
text,
|
||||||
|
token: parsed.data.token?.trim() || undefined,
|
||||||
|
chatId,
|
||||||
|
messageThreadId: threadId,
|
||||||
|
})
|
||||||
|
if (!send.ok) {
|
||||||
|
return reply.status(502).send({ ok: false, error: send.error })
|
||||||
|
}
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default alertsRoutes
|
||||||
+4
-148
@@ -2,154 +2,10 @@ import { eq } from "drizzle-orm"
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers } from "../db/schema.js"
|
import { servers } from "../db/schema.js"
|
||||||
|
import { parseBgpSessions } from "../services/bgp-parse-sessions.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { ServerIdParamSchema } from "../types/server.js"
|
import { ServerIdParamSchema } from "../types/server.js"
|
||||||
import type { RosBgpSession, BgpSessionRead } from "../types/server.js"
|
import type { 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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
|
||||||
@@ -176,7 +32,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
const raw = await client.getBgpSessions()
|
const raw = await client.getBgpSessions()
|
||||||
return parseSessions(server, raw)
|
return parseBgpSessions(server, raw)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -198,7 +54,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
const raw = await client.getBgpSessions()
|
const raw = await client.getBgpSessions()
|
||||||
return reply.send(parseSessions(server, raw))
|
return reply.send(parseBgpSessions(server, raw))
|
||||||
} catch {
|
} catch {
|
||||||
return reply.send([])
|
return reply.send([])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm"
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { evobgpSettings } from "../db/schema.js"
|
import { evobgpSettings, filterRules } from "../db/schema.js"
|
||||||
|
|
||||||
const PutEvobgpSchema = z.object({
|
const PutEvobgpSchema = z.object({
|
||||||
baseUrl: z.string().optional(),
|
baseUrl: z.string().optional(),
|
||||||
@@ -48,7 +48,14 @@ interface EvoCatalogRaw {
|
|||||||
asns: {
|
asns: {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
module_id: string
|
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: {
|
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 {
|
function problemMessage(text: string, status: number): string {
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(text) as { detail?: string; title?: string }
|
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 asns = (catalog.asns?.items ?? []).map((rowItem) => {
|
||||||
const mod = modMap.get(rowItem.module_id)
|
|
||||||
const cid = rowItem.entry.community_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 {
|
return {
|
||||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
id: `${rowItem.module_id}:${e.id}`,
|
||||||
asn: `AS${rowItem.entry.asn}`,
|
asn: `AS${e.asn}`,
|
||||||
org: "—",
|
org: name || "—",
|
||||||
country: "—",
|
country: "—",
|
||||||
prefixes: 0,
|
prefixes,
|
||||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||||
updated: fmtTime,
|
updated,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const communities = commItems.map((c) => ({
|
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
||||||
id: c.id,
|
const serversByCommunityStr = distinctServersByFilterCommunityValue()
|
||||||
value: c.community ?? c.id,
|
|
||||||
name: c.title ?? c.community ?? c.id,
|
const communities = commItems.map((c) => {
|
||||||
description: "",
|
const valueStr = (c.community ?? "").trim()
|
||||||
type: "custom" as const,
|
return {
|
||||||
filterIds: [] as string[],
|
id: c.id,
|
||||||
serverCount: 0,
|
value: c.community ?? c.id,
|
||||||
prefixCount: 0,
|
name: c.title ?? c.community ?? c.id,
|
||||||
action: "permit" as const,
|
description: "",
|
||||||
enabled: true,
|
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({
|
return reply.send({
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
|
|||||||
@@ -3,18 +3,10 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
|||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
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 {
|
interface RosGre {
|
||||||
".id"?: string
|
".id"?: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -95,23 +87,55 @@ function parseInnerIps(rule: string | undefined): { localInnerIp: string; remote
|
|||||||
|
|
||||||
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
|
||||||
const text = raw.rule ?? ""
|
const text = raw.rule ?? ""
|
||||||
// RouterOS rule formats vary across versions/config styles:
|
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
|
||||||
// - bgp-communities.has("AS:NNN")
|
const out: ApiFilterRule[] = []
|
||||||
// - 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 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 []
|
if (communities.length === 0) return []
|
||||||
|
const isBlackhole =
|
||||||
const uniqueCommunities = [...new Set(communities)]
|
/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 gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
|
||||||
const outIface = text.match(/set\s+out-interface\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 communities.map((community, idx) => ({
|
||||||
|
|
||||||
return uniqueCommunities.map((community, idx) => ({
|
|
||||||
id: `${raw[".id"] ?? "live"}-${idx}`,
|
id: `${raw[".id"] ?? "live"}-${idx}`,
|
||||||
community,
|
community,
|
||||||
action: isBlackhole ? "blackhole" : "route",
|
action: isBlackhole ? "blackhole" : "route",
|
||||||
@@ -331,7 +355,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
|
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
|
||||||
app.get("/filters/router-compare", async (req, reply) => {
|
app.get("/filters/router-compare", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
const q = req.query as { serverId?: string | number }
|
||||||
const serverId = parseServerId(q.serverId)
|
const serverId = parseDbServerId(q.serverId)
|
||||||
if (serverId === null) {
|
if (serverId === null) {
|
||||||
return reply.status(400).send({ error: "serverId is required" })
|
return reply.status(400).send({ error: "serverId is required" })
|
||||||
}
|
}
|
||||||
@@ -369,7 +393,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
|
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
|
||||||
app.get("/filters/gre-tunnels", async (req, reply) => {
|
app.get("/filters/gre-tunnels", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
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()
|
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||||
|
|
||||||
if (sid !== null) {
|
if (sid !== null) {
|
||||||
@@ -423,7 +447,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
app.post("/filters/sync/from-router", async (_req, reply) => {
|
app.post("/filters/sync/from-router", async (_req, reply) => {
|
||||||
const body = _req.body as { serverId?: string | number } | undefined
|
const body = _req.body as { serverId?: string | number } | undefined
|
||||||
const rawServerId = body?.serverId
|
const rawServerId = body?.serverId
|
||||||
const serverId = parseServerId(rawServerId)
|
const serverId = parseDbServerId(rawServerId)
|
||||||
if (serverId === null) {
|
if (serverId === null) {
|
||||||
return reply.status(400).send({ error: "serverId is required" })
|
return reply.status(400).send({ error: "serverId is required" })
|
||||||
}
|
}
|
||||||
|
|||||||
+400
-2
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { db } from "../db/index.js"
|
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 { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { ServerIdParamSchema } from "../types/server.js"
|
import { ServerIdParamSchema } from "../types/server.js"
|
||||||
import type {
|
import type {
|
||||||
@@ -9,8 +9,15 @@ import type {
|
|||||||
RosBfdSession,
|
RosBfdSession,
|
||||||
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
|
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
|
||||||
} from "../types/server.js"
|
} from "../types/server.js"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
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 ───────────────────────────────────────────────────────────────────
|
// ── 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 ──────────────────────────────────────────────────────────────
|
// ── route plugin ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
@@ -322,6 +651,75 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.status(500).send({ error: String(err) })
|
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
|
export default ospfRoutes
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
|||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { recursiveRoutes, servers } from "../db/schema.js"
|
import { recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -46,15 +47,6 @@ interface RecursiveRouteDto {
|
|||||||
disabled: boolean
|
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 {
|
function isIpGateway(gw: string): boolean {
|
||||||
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
|
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) => {
|
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/recursive-routes/gateways", async (req, reply) => {
|
app.get("/recursive-routes/gateways", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
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" })
|
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]
|
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" })
|
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) => {
|
app.get("/recursive-routes", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
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" })
|
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]
|
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" })
|
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) => {
|
app.put("/recursive-routes", async (req, reply) => {
|
||||||
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
|
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" })
|
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]
|
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" })
|
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) => {
|
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
|
||||||
const body = req.body as { serverId?: string | number } | undefined
|
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" })
|
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||||
const server: ServerRow | undefined = db
|
const server: ServerRow | undefined = db
|
||||||
.select().from(servers)
|
.select().from(servers)
|
||||||
@@ -235,7 +227,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
|
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
|
||||||
const body = req.body as { serverId?: string | number } | undefined
|
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" })
|
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]
|
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" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import {
|
||||||
|
getSchedulerStatus,
|
||||||
|
JOB_KEYS,
|
||||||
|
listSchedulerRuns,
|
||||||
|
refreshScheduler,
|
||||||
|
runSchedulerJobNow,
|
||||||
|
} from "../services/scheduler.js"
|
||||||
|
|
||||||
|
function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null {
|
||||||
|
const s = String(raw ?? "").trim()
|
||||||
|
return (JOB_KEYS as readonly string[]).includes(s) ? (s as typeof JOB_KEYS[number]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulerRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/scheduler/status", async (_req, reply) => {
|
||||||
|
return reply.send(getSchedulerStatus())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/scheduler/runs", async (req, reply) => {
|
||||||
|
const q = req.query as { jobKey?: string; limit?: string; offset?: string }
|
||||||
|
const jobKey = q.jobKey ? parseJobKey(q.jobKey) : undefined
|
||||||
|
if (q.jobKey && jobKey == null) {
|
||||||
|
return reply.status(400).send({ error: "Некорректный jobKey" })
|
||||||
|
}
|
||||||
|
const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50))
|
||||||
|
const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0)
|
||||||
|
return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset }))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/scheduler/refresh", async (_req, reply) => {
|
||||||
|
refreshScheduler()
|
||||||
|
return reply.send({ ok: true, status: getSchedulerStatus() })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => {
|
||||||
|
const jobKey = parseJobKey(String((req.params as { jobKey?: string }).jobKey ?? ""))
|
||||||
|
if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" })
|
||||||
|
try {
|
||||||
|
await runSchedulerJobNow(jobKey)
|
||||||
|
return reply.send({ ok: true, status: getSchedulerStatus() })
|
||||||
|
} catch (e) {
|
||||||
|
const statusCode = (e as Error & { statusCode?: number }).statusCode
|
||||||
|
if (statusCode === 409) {
|
||||||
|
return reply.status(409).send({ error: "Задача уже выполняется" })
|
||||||
|
}
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
return reply.status(500).send({ error: msg })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default schedulerRoutes
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||||
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
|
import {
|
||||||
|
collectServersRestPingOnce,
|
||||||
|
getServersApiPingSettings,
|
||||||
|
getServersRestPingCollectorState,
|
||||||
|
updateServersApiPingSettings,
|
||||||
|
} from "../services/servers-rest-ping-collector.js"
|
||||||
|
|
||||||
|
const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/servers-api-ping/settings", async (_req, reply) => {
|
||||||
|
const settings = getServersApiPingSettings()
|
||||||
|
const state = getServersRestPingCollectorState()
|
||||||
|
return reply.send({
|
||||||
|
enabled: settings.enabled,
|
||||||
|
intervalSec: settings.intervalSec,
|
||||||
|
lastCollectedAt: settings.lastCollectedAt ?? null,
|
||||||
|
lastDurationMs: settings.lastDurationMs ?? null,
|
||||||
|
lastError: settings.lastError || null,
|
||||||
|
collectorRunning: state.running,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/servers-api-ping/settings", async (req, reply) => {
|
||||||
|
const body = req.body as {
|
||||||
|
enabled?: boolean
|
||||||
|
intervalSec?: number | string
|
||||||
|
}
|
||||||
|
const intervalSec =
|
||||||
|
body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120)
|
||||||
|
const updated = updateServersApiPingSettings({
|
||||||
|
enabled: body.enabled,
|
||||||
|
intervalSec,
|
||||||
|
})
|
||||||
|
refreshScheduler()
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
settings: {
|
||||||
|
enabled: updated.enabled,
|
||||||
|
intervalSec: updated.intervalSec,
|
||||||
|
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||||
|
lastDurationMs: updated.lastDurationMs ?? null,
|
||||||
|
lastError: updated.lastError || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/servers-api-ping/collect-now", async (_req, reply) => {
|
||||||
|
await collectServersRestPingOnce()
|
||||||
|
scheduleAlertEngineAfterDataCollectors()
|
||||||
|
const row = getServersApiPingSettings()
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
lastCollectedAt: row.lastCollectedAt ?? null,
|
||||||
|
lastDurationMs: row.lastDurationMs ?? null,
|
||||||
|
lastError: row.lastError || null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default serversApiPingRoutes
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import {
|
||||||
|
filterRules,
|
||||||
|
recursiveRoutes,
|
||||||
|
servers,
|
||||||
|
uptimeProbes,
|
||||||
|
uptimeSpeedProbes,
|
||||||
|
} from "../db/schema.js"
|
||||||
|
|
||||||
|
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/sidebar-counts", async (_req, reply) => {
|
||||||
|
const [
|
||||||
|
serversTotal,
|
||||||
|
filterRulesTotal,
|
||||||
|
uptimeProbesTotal,
|
||||||
|
uptimeSpeedProbesTotal,
|
||||||
|
recursiveRoutesTotal,
|
||||||
|
] = [
|
||||||
|
db.select().from(servers).all().length,
|
||||||
|
db.select().from(filterRules).all().length,
|
||||||
|
db.select().from(uptimeProbes).all().length,
|
||||||
|
db.select().from(uptimeSpeedProbes).all().length,
|
||||||
|
db.select().from(recursiveRoutes).all().length,
|
||||||
|
]
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
servers: serversTotal,
|
||||||
|
filterRules: filterRulesTotal,
|
||||||
|
uptimeProbes: uptimeProbesTotal,
|
||||||
|
uptimeSpeedProbes: uptimeSpeedProbesTotal,
|
||||||
|
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||||
|
recursiveRoutes: recursiveRoutesTotal,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sidebarCountsRoutes
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
readServerSamplesInRange,
|
readServerSamplesInRange,
|
||||||
updateTrafficSettings,
|
updateTrafficSettings,
|
||||||
} from "../services/traffic-collector.js"
|
} from "../services/traffic-collector.js"
|
||||||
|
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||||
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
|
|
||||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
|
|
||||||
@@ -189,6 +191,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
intervalSec,
|
intervalSec,
|
||||||
retentionDays,
|
retentionDays,
|
||||||
})
|
})
|
||||||
|
refreshScheduler()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
settings: {
|
settings: {
|
||||||
@@ -204,6 +207,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
app.post("/traffic/collect-now", async (_req, reply) => {
|
app.post("/traffic/collect-now", async (_req, reply) => {
|
||||||
await collectTrafficOnce()
|
await collectTrafficOnce()
|
||||||
|
scheduleAlertEngineAfterDataCollectors()
|
||||||
const updated = getTrafficSettings()
|
const updated = getTrafficSettings()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
+125
-245
@@ -3,10 +3,11 @@ import { asc, desc, eq } from "drizzle-orm"
|
|||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.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 {
|
import {
|
||||||
|
collectPingForProbeIds,
|
||||||
collectUptimeOnce,
|
collectUptimeOnce,
|
||||||
parsePing,
|
|
||||||
readProbeRows,
|
readProbeRows,
|
||||||
readProbeSamplesSince,
|
readProbeSamplesSince,
|
||||||
readResourceSamplesSince,
|
readResourceSamplesSince,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
updateProbeShowOnDashboard,
|
updateProbeShowOnDashboard,
|
||||||
updateUptimeSettings,
|
updateUptimeSettings,
|
||||||
} from "../services/uptime-collector.js"
|
} from "../services/uptime-collector.js"
|
||||||
|
import { recordSpeedTestFailure, runBandwidthSpeedTest } from "../services/uptime-speed-service.js"
|
||||||
|
|
||||||
function rangeToMinutes(range: string | undefined): number {
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
switch ((range ?? "1h").toLowerCase()) {
|
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]
|
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */
|
||||||
|
type ResourceSampleRow = ReturnType<typeof readResourceSamplesSince>[number]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid,
|
* Последний сэмпл по времени может быть offline после сбоя опроса — тогда UI «пустой».
|
||||||
* среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP).
|
* Берём последний успешный (online + RAM), только если он не слишком старый относительно последней попытки,
|
||||||
|
* иначе снова показываем устаревший uptime/метрики как раньше.
|
||||||
*/
|
*/
|
||||||
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
|
function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): {
|
||||||
const name = iface.trim()
|
row: ResourceSampleRow | undefined
|
||||||
const candidates = rows.filter((a) => {
|
hasData: boolean
|
||||||
if (String(a.interface ?? "").trim() !== name) return false
|
} {
|
||||||
if (String(a.disabled ?? "false").toLowerCase() === "true") return false
|
if (rows.length === 0) return { row: undefined, hasData: false }
|
||||||
if (String(a.invalid ?? "false").toLowerCase() === "true") return false
|
const last = rows[rows.length - 1]!
|
||||||
const addr = String(a.address ?? "").trim()
|
const lastTs = Date.parse(last.sampledAt)
|
||||||
return addr.includes("/")
|
const validOnline = (r: ResourceSampleRow) => r.status === "online" && r.totalMemory > 0
|
||||||
})
|
|
||||||
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 {
|
if (validOnline(last)) {
|
||||||
const txt = String(raw ?? "").trim().toLowerCase()
|
return { row: last, hasData: true }
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fbTs = Date.parse(fallback.sampledAt)
|
||||||
const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b)
|
if (lastTs - fbTs <= maxGapMs) return { row: fallback, hasData: true }
|
||||||
const releases: Array<() => void> = []
|
return { row: undefined, hasData: false }
|
||||||
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 uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
@@ -104,19 +70,28 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const s = readUptimeSettings()
|
const s = readUptimeSettings()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
enabled: s.enabled,
|
enabled: s.enabled,
|
||||||
|
resourcesEnabled: s.resourcesEnabled ?? s.enabled,
|
||||||
|
pingEnabled: s.pingEnabled ?? s.enabled,
|
||||||
|
speedEnabled: s.speedEnabled ?? s.enabled,
|
||||||
|
/** Интервал сбора метрик ресурсов (сек). */
|
||||||
intervalSec: s.intervalSec,
|
intervalSec: s.intervalSec,
|
||||||
probeIntervalSec: s.probeIntervalSec ?? s.intervalSec,
|
/** Интервал тика ping и значение по умолчанию для проб без своего interval. */
|
||||||
|
probeIntervalSec: s.probeIntervalSec,
|
||||||
speedIntervalSec: s.speedIntervalSec ?? 60,
|
speedIntervalSec: s.speedIntervalSec ?? 60,
|
||||||
retentionDays: s.retentionDays,
|
retentionDays: s.retentionDays,
|
||||||
lastCollectedAt: s.lastCollectedAt ?? null,
|
lastCollectedAt: s.lastCollectedAt ?? null,
|
||||||
lastDurationMs: s.lastDurationMs ?? null,
|
lastDurationMs: s.lastDurationMs ?? null,
|
||||||
lastError: s.lastError || null,
|
lastError: s.lastError || null,
|
||||||
|
scheduler: getSchedulerStatus(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/uptime/settings", async (req, reply) => {
|
app.put("/uptime/settings", async (req, reply) => {
|
||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
resourcesEnabled?: boolean
|
||||||
|
pingEnabled?: boolean
|
||||||
|
speedEnabled?: boolean
|
||||||
intervalSec?: number | string
|
intervalSec?: number | string
|
||||||
probeIntervalSec?: number | string
|
probeIntervalSec?: number | string
|
||||||
speedIntervalSec?: number | string
|
speedIntervalSec?: number | string
|
||||||
@@ -124,28 +99,37 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
const updated = updateUptimeSettings({
|
const updated = updateUptimeSettings({
|
||||||
enabled: body.enabled,
|
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),
|
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),
|
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),
|
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
|
||||||
})
|
})
|
||||||
|
refreshScheduler()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
ok: true,
|
||||||
settings: {
|
settings: {
|
||||||
enabled: updated.enabled,
|
enabled: updated.enabled,
|
||||||
|
resourcesEnabled: updated.resourcesEnabled ?? updated.enabled,
|
||||||
|
pingEnabled: updated.pingEnabled ?? updated.enabled,
|
||||||
|
speedEnabled: updated.speedEnabled ?? updated.enabled,
|
||||||
intervalSec: updated.intervalSec,
|
intervalSec: updated.intervalSec,
|
||||||
probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec,
|
probeIntervalSec: updated.probeIntervalSec,
|
||||||
speedIntervalSec: updated.speedIntervalSec ?? 60,
|
speedIntervalSec: updated.speedIntervalSec ?? 60,
|
||||||
retentionDays: updated.retentionDays,
|
retentionDays: updated.retentionDays,
|
||||||
lastCollectedAt: updated.lastCollectedAt ?? null,
|
lastCollectedAt: updated.lastCollectedAt ?? null,
|
||||||
lastDurationMs: updated.lastDurationMs ?? null,
|
lastDurationMs: updated.lastDurationMs ?? null,
|
||||||
lastError: updated.lastError || null,
|
lastError: updated.lastError || null,
|
||||||
|
scheduler: getSchedulerStatus(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/uptime/collect-now", async (_req, reply) => {
|
app.post("/uptime/collect-now", async (_req, reply) => {
|
||||||
await collectUptimeOnce()
|
await collectUptimeOnce()
|
||||||
|
scheduleAlertEngineAfterDataCollectors()
|
||||||
const s = readUptimeSettings()
|
const s = readUptimeSettings()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
ok: true,
|
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) => {
|
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) => {
|
app.get("/uptime/speed-probes", async (_req, reply) => {
|
||||||
@@ -275,6 +293,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run()
|
}).run()
|
||||||
}
|
}
|
||||||
|
refreshScheduler()
|
||||||
return reply.send({ ok: true })
|
return reply.send({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,6 +308,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
filter?: string
|
filter?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
showOnDashboard?: boolean
|
showOnDashboard?: boolean
|
||||||
|
intervalSec?: number | string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
const normalized = (body.probes ?? [])
|
const normalized = (body.probes ?? [])
|
||||||
@@ -301,9 +321,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
probeFilter: String(p.filter ?? "—"),
|
probeFilter: String(p.filter ?? "—"),
|
||||||
enabled: p.enabled !== false,
|
enabled: p.enabled !== false,
|
||||||
showOnDashboard: p.showOnDashboard === true,
|
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)
|
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
|
||||||
replaceProbes(normalized)
|
replaceProbes(normalized)
|
||||||
|
refreshScheduler()
|
||||||
return reply.send({ ok: true })
|
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]
|
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" })
|
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
|
||||||
try {
|
try {
|
||||||
return await withBtestNodeLocks([srcId, dstId], async () => {
|
const result = await runBandwidthSpeedTest({
|
||||||
const protocol = body.protocol === "udp" ? "udp" : "tcp"
|
runId: String(body.runId ?? "").trim() || undefined,
|
||||||
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
|
probeId: String(body.probeId ?? "").trim() || undefined,
|
||||||
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
|
srcServerId: srcId,
|
||||||
const srcInterface = String(body.srcInterface ?? "").trim()
|
dstServerId: dstId,
|
||||||
const dstInterface = String(body.dstInterface ?? "").trim()
|
srcInterface: String(body.srcInterface ?? "").trim() || undefined,
|
||||||
const srcClient = MikrotikClient.fromServer(src)
|
dstInterface: String(body.dstInterface ?? "").trim() || undefined,
|
||||||
const dstAddressRows = dstInterface
|
protocol: body.protocol === "udp" ? "udp" : "tcp",
|
||||||
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
|
direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both",
|
||||||
: []
|
durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10),
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
return reply.send({ ok: true, result })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
|
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)
|
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
|
||||||
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
||||||
: raw
|
: raw
|
||||||
const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim()
|
if (/активного IP|Server not found/i.test(raw)) {
|
||||||
if (probeId) {
|
return reply.status(400).send({ error: message })
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
return reply.status(502).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 allServers = db.select().from(servers).all()
|
||||||
const probeRows = readProbeRows()
|
const probeRows = readProbeRows()
|
||||||
const probeSamples = readProbeSamplesSince(sinceIso)
|
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 probes = probeRows.map((p) => {
|
||||||
const rows = probeSamples.filter((r) => r.probeId === p.id)
|
const rows = probeSamples.filter((r) => r.probeId === p.id)
|
||||||
@@ -624,18 +503,19 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
const resources = allServers.map((s) => {
|
const resources = allServers.map((s) => {
|
||||||
const rows = readResourceSamplesSince(sinceIso, s.id)
|
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)
|
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
|
||||||
return {
|
return {
|
||||||
serverId: String(s.id),
|
serverId: String(s.id),
|
||||||
cpu: last?.cpuLoad ?? 0,
|
hasData,
|
||||||
|
cpu: pick?.cpuLoad ?? 0,
|
||||||
cpuHistory,
|
cpuHistory,
|
||||||
ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)),
|
ramUsed: Math.max(0, ((pick?.totalMemory ?? 0) - (pick?.freeMemory ?? 0)) / (1024 * 1024)),
|
||||||
ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)),
|
ramTotal: Math.max(0, (pick?.totalMemory ?? 0) / (1024 * 1024)),
|
||||||
hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)),
|
hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)),
|
||||||
hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)),
|
hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)),
|
||||||
uptimeSeconds: last?.uptimeSeconds ?? 0,
|
uptimeSeconds: pick?.uptimeSeconds ?? 0,
|
||||||
boardName: last?.boardName || "RouterBOARD",
|
boardName: pick?.boardName || "RouterBOARD",
|
||||||
temp: undefined as number | undefined,
|
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)
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import https from "node:https"
|
import https from "node:https"
|
||||||
import type { Server } from "../db/schema.js"
|
import type { Server } from "../db/schema.js"
|
||||||
|
import { parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||||
import type {
|
import type {
|
||||||
RosIdentity, RosInterface, RosIpAddress, RosResource,
|
RosIdentity, RosInterface, RosIpAddress, RosResource,
|
||||||
RosBgpSession,
|
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 ─────────────────────────────────────────────────────────────
|
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class MikrotikClient {
|
export class MikrotikClient {
|
||||||
@@ -228,14 +299,43 @@ export class MikrotikClient {
|
|||||||
return rosDelete(this.params, path, timeoutMs)
|
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 ──────────────────────────────────────────────────────────
|
// ── typed helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getIdentity(): Promise<RosIdentity> {
|
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> {
|
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[]> {
|
async getInterfaces(): Promise<RosInterface[]> {
|
||||||
@@ -277,6 +377,11 @@ export class MikrotikClient {
|
|||||||
return this.get<RosBfdSession[]>("/routing/bfd/session")
|
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 ────────────────────────────────────────
|
// ── extra endpoints for exec route ────────────────────────────────────────
|
||||||
|
|
||||||
async getIpRoutes(): Promise<RosIpRoute[]> {
|
async getIpRoutes(): Promise<RosIpRoute[]> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { servers, serverSnapshots } from "../db/schema.js"
|
|||||||
import type { SnapshotInsert } from "../db/schema.js"
|
import type { SnapshotInsert } from "../db/schema.js"
|
||||||
import type { SnapshotRead } from "../types/server.js"
|
import type { SnapshotRead } from "../types/server.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
|
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||||
|
|
||||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,10 +46,9 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
|||||||
|
|
||||||
const latencyMs = performance.now() - t0
|
const latencyMs = performance.now() - t0
|
||||||
|
|
||||||
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
|
const cpuLoad = parseRosCpuLoadPercent(resource["cpu-load"])
|
||||||
const cpuLoad = parseInt(resource["cpu-load"], 10)
|
const freeMem = parseRosDataSizeBytes(resource["free-memory"])
|
||||||
const freeMem = parseInt(resource["free-memory"], 10)
|
const totalMem = parseRosDataSizeBytes(resource["total-memory"])
|
||||||
const totalMem = parseInt(resource["total-memory"], 10)
|
|
||||||
|
|
||||||
Object.assign(partialSnap, {
|
Object.assign(partialSnap, {
|
||||||
status: "online",
|
status: "online",
|
||||||
@@ -57,9 +57,9 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
|||||||
rosVersion: resource["version"],
|
rosVersion: resource["version"],
|
||||||
boardName: resource["board-name"],
|
boardName: resource["board-name"],
|
||||||
uptime: resource["uptime"],
|
uptime: resource["uptime"],
|
||||||
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
|
cpuLoad,
|
||||||
freeMemory: isNaN(freeMem) ? null : freeMem,
|
freeMemory: freeMem,
|
||||||
totalMemory: isNaN(totalMem) ? null : totalMem,
|
totalMemory: totalMem,
|
||||||
rawInterfaces: JSON.stringify(ifaces),
|
rawInterfaces: JSON.stringify(ifaces),
|
||||||
rawIpAddresses: JSON.stringify(addresses),
|
rawIpAddresses: JSON.stringify(addresses),
|
||||||
} satisfies Partial<SnapshotInsert>)
|
} satisfies Partial<SnapshotInsert>)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.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"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
|
|
||||||
interface RosIfaceTraffic {
|
interface RosIfaceTraffic {
|
||||||
@@ -19,13 +21,7 @@ export interface TrafficCollectorState {
|
|||||||
lastError: string | null
|
lastError: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
let collecting = false
|
let collecting = false
|
||||||
const state: TrafficCollectorState = {
|
|
||||||
running: false,
|
|
||||||
lastRunAt: null,
|
|
||||||
lastError: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNum(raw: unknown): number {
|
function toNum(raw: unknown): number {
|
||||||
const n = Number.parseFloat(String(raw ?? "0"))
|
const n = Number.parseFloat(String(raw ?? "0"))
|
||||||
@@ -54,36 +50,74 @@ function cleanupOldSamples(retentionDays: number) {
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectTrafficOnce(): Promise<void> {
|
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||||
if (collecting) return
|
const sampledAt = new Date().toISOString()
|
||||||
|
if (collecting) {
|
||||||
|
return {
|
||||||
|
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||||
|
job: "traffic",
|
||||||
|
sampledAt,
|
||||||
|
skipped: true,
|
||||||
|
servers: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
collecting = true
|
collecting = true
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const now = new Date().toISOString()
|
const now = sampledAt
|
||||||
const settings = getSettingsRow()
|
const settings = getSettingsRow()
|
||||||
|
const snapshot: TrafficRunSnapshot = {
|
||||||
|
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||||
|
job: "traffic",
|
||||||
|
sampledAt: now,
|
||||||
|
servers: [],
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||||
|
|
||||||
for (const srv of enabledServers) {
|
for (const srv of enabledServers) {
|
||||||
|
const name = srv.name || srv.host
|
||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(srv)
|
const client = MikrotikClient.fromServer(srv)
|
||||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||||
if (ifaces.length === 0) continue
|
let sumRx = 0
|
||||||
db.insert(trafficSamples).values(
|
let sumTx = 0
|
||||||
ifaces.map((i) => ({
|
for (const i of ifaces) {
|
||||||
serverId: srv.id,
|
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
||||||
interfaceName: i.name ?? "unknown",
|
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
||||||
sampledAt: now,
|
}
|
||||||
rxBytes: toNum(i["rx-byte"]),
|
if (ifaces.length > 0) {
|
||||||
txBytes: toNum(i["tx-byte"]),
|
db.insert(trafficSamples).values(
|
||||||
rxBps: toNum(i["rx-bits-per-second"]),
|
ifaces.map((i) => ({
|
||||||
txBps: toNum(i["tx-bits-per-second"]),
|
serverId: srv.id,
|
||||||
running: (i.running ?? "false") === "true",
|
interfaceName: i.name ?? "unknown",
|
||||||
disabled: (i.disabled ?? "false") === "true",
|
sampledAt: now,
|
||||||
})),
|
rxBytes: toNum(i["rx-byte"]),
|
||||||
).run()
|
txBytes: toNum(i["tx-byte"]),
|
||||||
} catch {
|
rxBps: toNum(i["rx-bits-per-second"]),
|
||||||
// Continue collecting from remaining servers
|
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: "",
|
lastError: "",
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}).where(eq(trafficSettings.id, 1)).run()
|
}).where(eq(trafficSettings.id, 1)).run()
|
||||||
|
|
||||||
state.lastRunAt = now
|
|
||||||
state.lastError = null
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
snapshot.fatalError = msg
|
||||||
db.update(trafficSettings).set({
|
db.update(trafficSettings).set({
|
||||||
lastCollectedAt: now,
|
lastCollectedAt: now,
|
||||||
lastDurationMs: Date.now() - startedAt,
|
lastDurationMs: Date.now() - startedAt,
|
||||||
lastError: msg,
|
lastError: msg,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}).where(eq(trafficSettings.id, 1)).run()
|
}).where(eq(trafficSettings.id, 1)).run()
|
||||||
state.lastRunAt = now
|
|
||||||
state.lastError = msg
|
|
||||||
} finally {
|
} finally {
|
||||||
collecting = false
|
collecting = false
|
||||||
}
|
}
|
||||||
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopTrafficCollector() {
|
/** Таймеры трафика ведёт планировщик. */
|
||||||
if (timer) {
|
export function stopTrafficCollector() {}
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
state.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restartTrafficCollector() {
|
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 getTrafficCollectorState(): TrafficCollectorState {
|
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() {
|
export function getTrafficSettings() {
|
||||||
@@ -152,7 +179,6 @@ export function updateTrafficSettings(patch: {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
|
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
|
||||||
restartTrafficCollector()
|
|
||||||
return getSettingsRow()
|
return getSettingsRow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,4 +192,3 @@ export function readServerSamplesInRange(serverId: number, sinceIso: string) {
|
|||||||
.orderBy(asc(trafficSamples.sampledAt))
|
.orderBy(asc(trafficSamples.sampledAt))
|
||||||
.all()
|
.all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { db } from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
servers,
|
servers,
|
||||||
@@ -7,31 +7,18 @@ import {
|
|||||||
uptimeResourceSamples,
|
uptimeResourceSamples,
|
||||||
uptimeSettings,
|
uptimeSettings,
|
||||||
} from "../db/schema.js"
|
} 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 { MikrotikClient } from "./mikrotik.js"
|
||||||
|
import { parseRosCpuLoadPercent, parseRosDataSizeBytes, parseRosUptimeToSeconds } from "./ros-metric-parse.js"
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let collectingResources = false
|
||||||
let collecting = false
|
let collectingPing = false
|
||||||
|
|
||||||
function parseDurationToSeconds(raw: string | undefined): number {
|
/** Параллельный прогон сборщиков uptime — чтобы `alert_engine` не читал БД до коммита сэмплов. */
|
||||||
if (!raw) return 0
|
export function getUptimeCollectorsState(): { resources: boolean; ping: boolean } {
|
||||||
let total = 0
|
return { resources: collectingResources, ping: collectingPing }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePingTimeMs(raw: string | undefined): number | null {
|
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$/)
|
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
|
||||||
if (sec) return Number.parseFloat(sec[1]) * 1000
|
if (sec) return Number.parseFloat(sec[1]) * 1000
|
||||||
|
|
||||||
// HH:MM:SS(.sss) from some RouterOS outputs
|
|
||||||
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
|
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
|
||||||
if (clock) {
|
if (clock) {
|
||||||
const h = Number.parseFloat(clock[1])
|
const h = Number.parseFloat(clock[1])
|
||||||
@@ -57,16 +43,19 @@ function parsePingTimeMs(raw: string | undefined): number | null {
|
|||||||
return Number.isFinite(n) ? n : 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]
|
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
|
||||||
if (row) return row
|
if (row) return row
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
db.insert(uptimeSettings).values({
|
db.insert(uptimeSettings).values({
|
||||||
id: 1,
|
id: 1,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalSec: 15,
|
resourcesEnabled: true,
|
||||||
|
pingEnabled: true,
|
||||||
|
speedEnabled: true,
|
||||||
|
intervalSec: 300,
|
||||||
probeIntervalSec: 15,
|
probeIntervalSec: 15,
|
||||||
speedIntervalSec: 60,
|
speedIntervalSec: 3600,
|
||||||
retentionDays: 14,
|
retentionDays: 14,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -105,38 +94,101 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent?
|
|||||||
typeof lossSource === "number" && Number.isFinite(lossSource)
|
typeof lossSource === "number" && Number.isFinite(lossSource)
|
||||||
? Math.max(0, Math.min(100, lossSource))
|
? Math.max(0, Math.min(100, lossSource))
|
||||||
: 100
|
: 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 }
|
return { avgRtt, loss, status }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectUptimeOnce(): Promise<void> {
|
/** Узлы с REST-опросом для uptime: включённые в каталоге или любой Home Router (см. /servers — HR часто «выкл.» при доступе только из LAN). */
|
||||||
if (collecting) return
|
function serversForUptimeRosPoll() {
|
||||||
collecting = true
|
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 started = Date.now()
|
||||||
const now = new Date().toISOString()
|
const now = sampledAt
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
|
const snapshot: ResourcesRunSnapshot = {
|
||||||
|
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||||
|
job: "uptime_resources",
|
||||||
|
sampledAt: now,
|
||||||
|
servers: [],
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
try {
|
||||||
const client = MikrotikClient.fromServer(s)
|
const client = MikrotikClient.fromServer(s)
|
||||||
const resource = await client.getResource()
|
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({
|
db.insert(uptimeResourceSamples).values({
|
||||||
serverId: s.id,
|
serverId: s.id,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
status: "online",
|
status: "online",
|
||||||
cpuLoad: toNum(resource["cpu-load"]),
|
cpuLoad,
|
||||||
freeMemory: toNum(resource["free-memory"]),
|
freeMemory: freeMem,
|
||||||
totalMemory: toNum(resource["total-memory"]),
|
totalMemory: totalMem,
|
||||||
freeHddSpace: toNum(resource["free-hdd-space"]),
|
freeHddSpace: freeHdd,
|
||||||
totalHddSpace: toNum(resource["total-hdd-space"]),
|
totalHddSpace: totalHdd,
|
||||||
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
|
uptimeSeconds,
|
||||||
boardName: String(resource["board-name"] ?? ""),
|
boardName: String(resource["board-name"] ?? ""),
|
||||||
rosVersion: String(resource["version"] ?? ""),
|
rosVersion: String(resource["version"] ?? ""),
|
||||||
}).run()
|
}).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({
|
db.insert(uptimeResourceSamples).values({
|
||||||
serverId: s.id,
|
serverId: s.id,
|
||||||
sampledAt: now,
|
sampledAt: now,
|
||||||
@@ -150,12 +202,180 @@ export async function collectUptimeOnce(): Promise<void> {
|
|||||||
boardName: "",
|
boardName: "",
|
||||||
rosVersion: "",
|
rosVersion: "",
|
||||||
}).run()
|
}).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()
|
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||||
|
|
||||||
for (const p of probes) {
|
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
|
if (!src) continue
|
||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(src)
|
const client = MikrotikClient.fromServer(src)
|
||||||
@@ -177,64 +397,74 @@ export async function collectUptimeOnce(): Promise<void> {
|
|||||||
status: "down",
|
status: "down",
|
||||||
}).run()
|
}).run()
|
||||||
}
|
}
|
||||||
|
polled += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup(Math.max(1, settings.retentionDays))
|
cleanup(Math.max(1, settings.retentionDays))
|
||||||
db.update(uptimeSettings).set({
|
return { polled }
|
||||||
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()
|
|
||||||
} finally {
|
} finally {
|
||||||
collecting = false
|
collectingPing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restartUptimeCollector() {
|
/** Совместимость: один проход ресурсы + ping (для ручного вызова без планировщика). */
|
||||||
const s = getSettings()
|
export async function collectUptimeOnce(): Promise<void> {
|
||||||
if (timer) clearInterval(timer)
|
await collectResourceSamplesOnce()
|
||||||
timer = null
|
await collectPingProbesOnce()
|
||||||
if (!s.enabled) return
|
|
||||||
const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000
|
|
||||||
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopUptimeCollector() {
|
/** no-op: таймеры управляются планировщиком */
|
||||||
if (timer) clearInterval(timer)
|
export function restartUptimeCollector() {}
|
||||||
timer = null
|
|
||||||
}
|
export function stopUptimeCollector() {}
|
||||||
|
|
||||||
export function readUptimeSettings() {
|
export function readUptimeSettings() {
|
||||||
return getSettings()
|
return getSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUptimeSettings(patch: {
|
export function updateUptimeSettings(patch: {
|
||||||
|
/** Устаревший: если передан без per-job флагов — выставляет все три job-флага. */
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
resourcesEnabled?: boolean
|
||||||
|
pingEnabled?: boolean
|
||||||
|
speedEnabled?: boolean
|
||||||
|
/** Интервал сбора метрик ресурсов (сек). */
|
||||||
intervalSec?: number
|
intervalSec?: number
|
||||||
|
/** Интервал тика ping и значение по умолчанию для проб без своего interval_sec. */
|
||||||
probeIntervalSec?: number
|
probeIntervalSec?: number
|
||||||
speedIntervalSec?: number
|
speedIntervalSec?: number
|
||||||
retentionDays?: number
|
retentionDays?: number
|
||||||
}) {
|
}) {
|
||||||
const prev = getSettings()
|
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 = {
|
const next = {
|
||||||
enabled: patch.enabled ?? prev.enabled,
|
enabled: masterEnabled,
|
||||||
intervalSec: nextProbeInterval,
|
resourcesEnabled,
|
||||||
probeIntervalSec: nextProbeInterval,
|
pingEnabled,
|
||||||
|
speedEnabled,
|
||||||
|
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||||
|
probeIntervalSec: patch.probeIntervalSec ?? prev.probeIntervalSec,
|
||||||
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
|
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
|
||||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
|
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
|
||||||
restartUptimeCollector()
|
|
||||||
return readUptimeSettings()
|
return readUptimeSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,20 +490,20 @@ export function replaceProbes(rows: Array<{
|
|||||||
probeFilter: string
|
probeFilter: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
showOnDashboard?: boolean
|
showOnDashboard?: boolean
|
||||||
|
intervalSec?: number
|
||||||
}>) {
|
}>) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
|
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
|
||||||
const nextIds = new Set(rows.map((r) => r.id))
|
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) {
|
for (const p of existing) {
|
||||||
if (nextIds.has(p.id)) continue
|
if (nextIds.has(p.id)) continue
|
||||||
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
|
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) {
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
const r = rows[i]
|
const r = rows[i]
|
||||||
|
const intervalSec = r.intervalSec != null && r.intervalSec > 0 ? Math.max(5, Math.floor(r.intervalSec)) : 0
|
||||||
const patch = {
|
const patch = {
|
||||||
srcServerId: r.srcServerId,
|
srcServerId: r.srcServerId,
|
||||||
srcInterface: r.srcInterface || "",
|
srcInterface: r.srcInterface || "",
|
||||||
@@ -282,6 +512,7 @@ export function replaceProbes(rows: Array<{
|
|||||||
probeFilter: r.probeFilter || "—",
|
probeFilter: r.probeFilter || "—",
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
showOnDashboard: r.showOnDashboard ?? false,
|
showOnDashboard: r.showOnDashboard ?? false,
|
||||||
|
intervalSec,
|
||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -311,4 +542,3 @@ export function readResourceSamplesSince(sinceIso: string, serverId: number) {
|
|||||||
.orderBy(asc(uptimeResourceSamples.sampledAt))
|
.orderBy(asc(uptimeResourceSamples.sampledAt))
|
||||||
.all()
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -30,6 +30,7 @@ import {
|
|||||||
GitMergeIcon,
|
GitMergeIcon,
|
||||||
TerminalIcon,
|
TerminalIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
DatabaseIcon,
|
||||||
CableIcon,
|
CableIcon,
|
||||||
ScanLineIcon,
|
ScanLineIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
@@ -39,6 +40,13 @@ import {
|
|||||||
BoxIcon,
|
BoxIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
} from "lucide-react"
|
} 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 ──────────────────────────────────────────
|
// ─── 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: "Обзор",
|
label: "Обзор",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
|
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
|
||||||
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
|
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
|
||||||
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
|
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
|
||||||
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon />, badge: "8" },
|
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Данные",
|
label: "Данные",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Домены", url: "/domains", icon: <GlobeIcon />, badge: "1.8к" },
|
{ title: "Домены", url: "/domains", icon: <GlobeIcon /> },
|
||||||
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon />, badge: "4.2к" },
|
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon /> },
|
||||||
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
|
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
|
||||||
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
|
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Управление",
|
label: "Управление",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Серверы", url: "/servers", icon: <ServerIcon />, badge: "9" },
|
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
|
||||||
{ title: "Фильтры", url: "/filters", icon: <FilterIcon />, badge: "6" },
|
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
|
||||||
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
|
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
|
||||||
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
|
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
|
||||||
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon />, badge: "4" },
|
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
|
||||||
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon />, badge: "6" },
|
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
|
||||||
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
|
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
|
||||||
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon />, badge: "5" },
|
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
|
||||||
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon />, badge: "2" },
|
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon /> },
|
||||||
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
|
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Инструменты",
|
label: "Инструменты",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
|
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
|
||||||
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
|
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
|
||||||
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon />, badge: "12" },
|
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon /> },
|
||||||
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
|
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
|
||||||
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
|
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Система",
|
label: "Система",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
|
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
|
||||||
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
|
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
|
||||||
|
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number }
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { resolvedTheme, setTheme } = useTheme()
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||||
|
const evo = useEvoBGP()
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||||
|
|
||||||
|
const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
setMounted(true)
|
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"
|
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||||
|
|
||||||
return (
|
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>
|
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Command palette trigger */}
|
|
||||||
<CommandPaletteButton />
|
<CommandPaletteButton />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
@@ -143,16 +235,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
|
<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 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>
|
</div>
|
||||||
{/* Name / email */}
|
|
||||||
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
<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-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
|
||||||
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Theme toggle — только после mount, иначе SSR и клиент расходятся (next-themes). */}
|
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
|
||||||
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
|
||||||
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
|
||||||
TerminalIcon, BellIcon, SettingsIcon,
|
TerminalIcon, BellIcon, SettingsIcon, DatabaseIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
// ─── Command item definition ──────────────────────────────────────────────────
|
// ─── Command item definition ──────────────────────────────────────────────────
|
||||||
@@ -52,8 +52,9 @@ const ALL_ITEMS: CommandItem[] = [
|
|||||||
{ id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: <ScanLineIcon />, keywords: ["probe","ping","traceroute","диагностика"] },
|
{ 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: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: <TerminalIcon />, keywords: ["terminal","cli","ssh","консоль"] },
|
||||||
// Система
|
// Система
|
||||||
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
|
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
|
||||||
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
|
{ 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 ──────────────────────────────────────────────────────
|
// ─── Scoring / filtering ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface NavItem {
|
|||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
|
/** Если `undefined` — счётчик не показываем (нет данных или раздел без live-источника). */
|
||||||
badge?: string
|
badge?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export function NavMain({ groups }: { groups: NavGroup[] }) {
|
|||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span>{item.title}</span>
|
<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">
|
<span className="ml-auto text-[10.5px] font-mono tabular-nums text-sidebar-foreground/50 group-data-[collapsible=icon]:hidden">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ export function wanUplinkIpMatchesGreOuter(
|
|||||||
if (!a || !b) return false
|
if (!a || !b) return false
|
||||||
if (a === b) return true
|
if (a === b) return true
|
||||||
if (resolvedIpv4ByHost) {
|
if (resolvedIpv4ByHost) {
|
||||||
const r = resolvedIpv4ByHost.get(b.toLowerCase())
|
const rb = resolvedIpv4ByHost.get(b.toLowerCase())
|
||||||
if (r && a === normalizeGreEndpointAddr(r)) return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { GreTunnel, Server } from "@/lib/data"
|
import type { GreTunnel, Server } from "@/lib/data"
|
||||||
|
import type { WanJhEdge } from "@/lib/network-map-layout"
|
||||||
import {
|
import {
|
||||||
formatGreOuterForDisplay,
|
formatGreOuterForDisplay,
|
||||||
normalizeGreEndpointAddr,
|
normalizeGreEndpointAddr,
|
||||||
@@ -24,6 +25,13 @@ export type GreTunnelProbeEdge = {
|
|||||||
toServer: Server
|
toServer: Server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Неориентированная пара узлов: один ключ для HR→MSK и MSK→HR (две строки GRE в БД). */
|
||||||
|
export function canonicalGreServerPairKey(a: Pick<Server, "id">, b: Pick<Server, "id">): string {
|
||||||
|
const x = String(a.id)
|
||||||
|
const y = String(b.id)
|
||||||
|
return x <= y ? `${x}\t${y}` : `${y}\t${x}`
|
||||||
|
}
|
||||||
|
|
||||||
function normIface(i?: string | null): string {
|
function normIface(i?: string | null): string {
|
||||||
return (i ?? "").trim().toLowerCase()
|
return (i ?? "").trim().toLowerCase()
|
||||||
}
|
}
|
||||||
@@ -122,6 +130,10 @@ function probesForServerPair(
|
|||||||
/**
|
/**
|
||||||
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
|
* Для всех GRE на карте назначает speed-пробы без повторного использования одной пробы
|
||||||
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
|
* на два разных туннеля между одной и той же парой узлов (RT / MTS и т.п.).
|
||||||
|
*
|
||||||
|
* Пара узлов **неориентированная**: туннель в БД на HR→MSK и зеркальная запись MSK→HR
|
||||||
|
* попадают в одну группу — иначе обе стороны независимо выбирают одни и те же пробы
|
||||||
|
* и бейджи дублируются на разных линиях карты.
|
||||||
*/
|
*/
|
||||||
export function assignSpeedProbesToGreTunnels(
|
export function assignSpeedProbesToGreTunnels(
|
||||||
edges: GreTunnelProbeEdge[],
|
edges: GreTunnelProbeEdge[],
|
||||||
@@ -132,7 +144,7 @@ export function assignSpeedProbesToGreTunnels(
|
|||||||
|
|
||||||
const byPair = new Map<string, GreTunnelProbeEdge[]>()
|
const byPair = new Map<string, GreTunnelProbeEdge[]>()
|
||||||
for (const e of edges) {
|
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) ?? []
|
const arr = byPair.get(k) ?? []
|
||||||
arr.push(e)
|
arr.push(e)
|
||||||
byPair.set(k, arr)
|
byPair.set(k, arr)
|
||||||
@@ -308,3 +320,170 @@ export function mergeGreMetricsWithSpeedProbe(
|
|||||||
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
|
sp?.lastRxAvgMbps != null ? Math.round(sp.lastRxAvgMbps) : fallback.ulMbps
|
||||||
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
|
return { pingMs, dlMbps, ulMbps, hasSpeedMonitor }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WAN satellite → JumpHost: speed-пробы с src = Home Router и выбранным WAN-iface ──
|
||||||
|
|
||||||
|
export function wanJhEdgeMapKey(edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">): string {
|
||||||
|
return `${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreSpeedProbeForWanJhEdge(
|
||||||
|
p: GreSpeedProbeSnapshot,
|
||||||
|
edge: Pick<WanJhEdge, "homeId" | "wanIdx" | "jhId">,
|
||||||
|
home: Server,
|
||||||
|
): number {
|
||||||
|
if (String(p.srcServerId) !== edge.homeId || String(p.dstServerId) !== edge.jhId) return -1
|
||||||
|
if (p.enabled === false) return -1
|
||||||
|
const wanIface = normIface(home.wanUplinks?.[edge.wanIdx]?.iface)
|
||||||
|
const si = normIface(p.srcInterface)
|
||||||
|
if (si && wanIface && si !== wanIface) return -1
|
||||||
|
let s = 0
|
||||||
|
if (si && wanIface && si === wanIface) s += 100
|
||||||
|
else if (!si && wanIface) s += 35
|
||||||
|
else if (!wanIface && si) s += 15
|
||||||
|
else if (!si && !wanIface) s += 5
|
||||||
|
if (p.lastPingRttMs != null || p.lastTxAvgMbps != null || p.lastRxAvgMbps != null) s += 6
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function probesHomeToJh(allProbes: GreSpeedProbeSnapshot[], homeId: string, jhId: string): GreSpeedProbeSnapshot[] {
|
||||||
|
return allProbes.filter((p) => {
|
||||||
|
if (p.enabled === false) return false
|
||||||
|
return String(p.srcServerId) === homeId && String(p.dstServerId) === jhId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignSpeedProbesWithinWanJhGroup(
|
||||||
|
edges: WanJhEdge[],
|
||||||
|
home: Server,
|
||||||
|
allProbes: GreSpeedProbeSnapshot[],
|
||||||
|
): Map<string, GreSpeedProbeSnapshot | undefined> {
|
||||||
|
const out = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||||
|
if (edges.length === 0) return out
|
||||||
|
const E = [...edges].sort((a, b) => a.wanIdx - b.wanIdx || a.jhId.localeCompare(b.jhId))
|
||||||
|
const pairProbes = probesHomeToJh(allProbes, E[0].homeId, E[0].jhId)
|
||||||
|
|
||||||
|
if (E.length === 1) {
|
||||||
|
let best: GreSpeedProbeSnapshot | undefined
|
||||||
|
let bestS = -1
|
||||||
|
for (const p of pairProbes) {
|
||||||
|
const sc = scoreSpeedProbeForWanJhEdge(p, E[0], home)
|
||||||
|
if (sc > bestS) {
|
||||||
|
bestS = sc
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.set(wanJhEdgeMapKey(E[0]), best != null && bestS >= 0 ? best : undefined)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = E.length
|
||||||
|
const m = pairProbes.length
|
||||||
|
if (m === 0) {
|
||||||
|
for (const e of E) out.set(wanJhEdgeMapKey(e), undefined)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestSum = -Infinity
|
||||||
|
let bestAssign: Map<string, GreSpeedProbeSnapshot> | null = null
|
||||||
|
|
||||||
|
function dfs(i: number, used: Set<string>, cur: Map<string, GreSpeedProbeSnapshot>) {
|
||||||
|
if (i === n) {
|
||||||
|
let sum = 0
|
||||||
|
for (const e of E) {
|
||||||
|
const p = cur.get(wanJhEdgeMapKey(e))
|
||||||
|
if (!p) return
|
||||||
|
sum += scoreSpeedProbeForWanJhEdge(p, e, home)
|
||||||
|
}
|
||||||
|
if (sum > bestSum) {
|
||||||
|
bestSum = sum
|
||||||
|
bestAssign = new Map(cur)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const edge = E[i]!
|
||||||
|
const slotKey = wanJhEdgeMapKey(edge)
|
||||||
|
for (const p of pairProbes) {
|
||||||
|
if (used.has(p.id)) continue
|
||||||
|
if (scoreSpeedProbeForWanJhEdge(p, edge, home) < 0) continue
|
||||||
|
used.add(p.id)
|
||||||
|
cur.set(slotKey, p)
|
||||||
|
dfs(i + 1, used, cur)
|
||||||
|
cur.delete(slotKey)
|
||||||
|
used.delete(p.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n <= 7 && n <= m) dfs(0, new Set(), new Map())
|
||||||
|
|
||||||
|
const inject = bestAssign as Map<string, GreSpeedProbeSnapshot> | null
|
||||||
|
if (inject !== null && inject.size === n) {
|
||||||
|
for (const ed of E) out.set(wanJhEdgeMapKey(ed), inject.get(wanJhEdgeMapKey(ed)))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const used = new Set<string>()
|
||||||
|
const remaining = new Set(E.map((e) => wanJhEdgeMapKey(e)))
|
||||||
|
while (remaining.size > 0) {
|
||||||
|
let pickEdge: WanJhEdge | undefined
|
||||||
|
let pickProbe: GreSpeedProbeSnapshot | undefined
|
||||||
|
let pickScore = -Infinity
|
||||||
|
for (const key of remaining) {
|
||||||
|
const e = E.find((x) => wanJhEdgeMapKey(x) === key)!
|
||||||
|
for (const p of pairProbes) {
|
||||||
|
if (used.has(p.id)) continue
|
||||||
|
const sc = scoreSpeedProbeForWanJhEdge(p, e, home)
|
||||||
|
if (sc < 0) continue
|
||||||
|
if (
|
||||||
|
sc > pickScore ||
|
||||||
|
(sc === pickScore && pickProbe != null && p.id.localeCompare(pickProbe.id) < 0)
|
||||||
|
) {
|
||||||
|
pickScore = sc
|
||||||
|
pickEdge = e
|
||||||
|
pickProbe = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pickEdge || !pickProbe) {
|
||||||
|
for (const key of remaining) out.set(key, undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.set(wanJhEdgeMapKey(pickEdge), pickProbe)
|
||||||
|
used.add(pickProbe.id)
|
||||||
|
remaining.delete(wanJhEdgeMapKey(pickEdge))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Назначает speed-пробы сегментам WAN→JH (Home → спутник → JH): по паре серверов и iface источника,
|
||||||
|
* без повторного использования одной пробы на два WAN одного home→jh (RT / MTS).
|
||||||
|
*/
|
||||||
|
export function assignSpeedProbesToWanJhEdges(
|
||||||
|
edges: WanJhEdge[],
|
||||||
|
servers: Server[],
|
||||||
|
probes: GreSpeedProbeSnapshot[],
|
||||||
|
): ReadonlyMap<string, GreSpeedProbeSnapshot | undefined> {
|
||||||
|
const result = new Map<string, GreSpeedProbeSnapshot | undefined>()
|
||||||
|
const homeById = new Map(servers.filter((s) => s.type === "home-router").map((s) => [s.id, s] as const))
|
||||||
|
|
||||||
|
const byPair = new Map<string, WanJhEdge[]>()
|
||||||
|
for (const e of edges) {
|
||||||
|
const k = `${e.homeId}\t${e.jhId}`
|
||||||
|
const arr = byPair.get(k) ?? []
|
||||||
|
arr.push(e)
|
||||||
|
byPair.set(k, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of byPair.values()) {
|
||||||
|
const home = homeById.get(group[0].homeId)
|
||||||
|
if (!home) {
|
||||||
|
for (const e of group) result.set(wanJhEdgeMapKey(e), undefined)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const assigned = assignSpeedProbesWithinWanJhGroup(group, home, probes)
|
||||||
|
for (const [k, sp] of assigned) result.set(k, sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
+172
-11
@@ -46,7 +46,7 @@ export const NETWORK_MAP_PIPELINE_Y = 300
|
|||||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||||
*/
|
*/
|
||||||
export const NETWORK_MAP_LAYOUT_REVISION = 3
|
export const NETWORK_MAP_LAYOUT_REVISION = 5
|
||||||
|
|
||||||
export interface WanJhEdge {
|
export interface WanJhEdge {
|
||||||
homeId: string
|
homeId: string
|
||||||
@@ -106,6 +106,41 @@ function spreadInSegment(count: number, index: number, xMin: number, xMax: numbe
|
|||||||
return xMin + (index / (count - 1)) * (xMax - xMin)
|
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. */
|
/** Детерминированные координаты узлов и «спутников» WAN под home-router. */
|
||||||
export function computeNetworkMapLayout(servers: Server[]): {
|
export function computeNetworkMapLayout(servers: Server[]): {
|
||||||
nodePos: Record<string, { x: number; y: number }>
|
nodePos: Record<string, { x: number; y: number }>
|
||||||
@@ -119,21 +154,70 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
|||||||
const laneW = (span - 2 * laneGap) / 3
|
const laneW = (span - 2 * laneGap) / 3
|
||||||
|
|
||||||
const laneOrder: ServerType[] = ["home-router", "jump-host", "exit-node"]
|
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
|
let xLane = MARGIN
|
||||||
for (const type of laneOrder) {
|
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 xMin = xLane
|
||||||
const xMax = xLane + laneW
|
const xMax = xLane + laneW
|
||||||
|
const laneCenterX = (xMin + xMax) / 2
|
||||||
group.forEach((s, i) => {
|
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] = {
|
nodePos[s.id] = {
|
||||||
x: spreadInSegment(group.length, i, xMin, xMax),
|
// Жёсткие колонки: Gateway слева, JH по центру, EN справа.
|
||||||
y: NETWORK_MAP_PIPELINE_Y,
|
x: laneCenterX,
|
||||||
|
y: clamp(baseline + typeYOffset[type] + spread, minNodeY, maxNodeY),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
xLane += laneW + laneGap
|
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»). */
|
/** Центр колонки jump-host — спутники WAN ставим на X между HR и JH (как на схеме «шлюз → провайдеры → JH»). */
|
||||||
const jhColumnCenterX = MARGIN + laneW + laneGap + laneW / 2
|
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 base = nodePos[r.id] ?? { x: W / 2, y: NETWORK_MAP_PIPELINE_Y }
|
||||||
const n = wans.length
|
const n = wans.length
|
||||||
const satX = (base.x + jhColumnCenterX) / 2
|
const satX = (base.x + jhColumnCenterX) / 2
|
||||||
const rowY = NETWORK_MAP_PIPELINE_Y
|
const rowY = base.y
|
||||||
const maxV = H - 2 * MARGIN - 100
|
const maxV = H - 2 * MARGIN - 100
|
||||||
const vGap =
|
const vGap =
|
||||||
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
n <= 1 ? 0 : Math.min(56, maxV / Math.max(1, n - 1))
|
||||||
@@ -163,8 +247,9 @@ export function computeNetworkMapLayout(servers: Server[]): {
|
|||||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||||
*/
|
*/
|
||||||
function legPing(home: Server, jh: Server): number | null {
|
function legPing(home: Server, jh: Server): number | null {
|
||||||
const hOk = home.enabled && home.status !== "offline"
|
/** Для линий на карте достаточно «живого» статуса; `enabled` не скрывает топологию. */
|
||||||
const jOk = jh.enabled && jh.status !== "offline"
|
const hOk = home.status !== "offline"
|
||||||
|
const jOk = jh.status !== "offline"
|
||||||
if (!hOk || !jOk) return null
|
if (!hOk || !jOk) return null
|
||||||
if (home.latency == null || jh.latency == null) return null
|
if (home.latency == null || jh.latency == null) return null
|
||||||
return Math.min(995, Math.round(home.latency + jh.latency))
|
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` с бекенда — те же объекты, что и в каталоге серверов). */
|
/** Рёбра WAN→JumpHost для отрисовки (`latency` с бекенда — те же объекты, что и в каталоге серверов). */
|
||||||
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
export function buildWanJhEdges(servers: Server[]): WanJhEdge[] {
|
||||||
const homes = servers.filter((s) => s.type === "home-router" && s.enabled)
|
const homes = servers.filter((s) => s.type === "home-router")
|
||||||
const jhs = servers.filter((s) => s.type === "jump-host" && s.enabled && s.status !== "offline")
|
const jhs = servers.filter((s) => s.type === "jump-host" && s.status !== "offline")
|
||||||
if (homes.length === 0 || jhs.length === 0) return []
|
if (homes.length === 0 || jhs.length === 0) return []
|
||||||
|
|
||||||
const edges: WanJhEdge[] = []
|
const edges: WanJhEdge[] = []
|
||||||
@@ -307,6 +392,41 @@ function hostWithoutPort(host: string): string {
|
|||||||
return h
|
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.
|
* Найти сервер в каталоге по GRE remote-address: совпадение с management host или WAN IP.
|
||||||
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
* Для FQDN в remote-address — передайте карту DNS из `/api/network/resolve-hosts`.
|
||||||
@@ -432,6 +552,36 @@ export function buildGreMapEdges(
|
|||||||
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
resolvedIpv4ByHost?: ReadonlyMap<string, string>,
|
||||||
): GreMapEdge[] {
|
): GreMapEdge[] {
|
||||||
const out: 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) {
|
for (const t of tunnels) {
|
||||||
const fromServer = servers.find((s) => s.id === t.serverId)
|
const fromServer = servers.find((s) => s.id === t.serverId)
|
||||||
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
const toServer = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
|
||||||
@@ -440,7 +590,18 @@ export function buildGreMapEdges(
|
|||||||
|
|
||||||
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
const from = greEndpointAnchor(fromServer, t, "source", nodePos, wanSatPos, resolvedIpv4ByHost)
|
||||||
const to = greEndpointAnchor(toServer, t, "peer", 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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -3,7 +3,7 @@
|
|||||||
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
|
* (см. блок `#route-ai` в `/settings` и страницу `/route-optimizer`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FilterRule } from "@/lib/data"
|
import { servers, type FilterRule } from "@/lib/data"
|
||||||
|
|
||||||
// ── Совместимо с app/(main)/route-optimizer/page.tsx ─────────────────────────
|
// ── Совместимо с app/(main)/route-optimizer/page.tsx ─────────────────────────
|
||||||
|
|
||||||
@@ -130,6 +130,15 @@ export interface OptimizerApiServer {
|
|||||||
status: "online" | "offline" | null
|
status: "online" | "offline" | null
|
||||||
latency: number | null
|
latency: number | null
|
||||||
model: string | null
|
model: string | null
|
||||||
|
wanUplinks?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
isp: string
|
||||||
|
iface: string
|
||||||
|
ip: string
|
||||||
|
maxDl: number
|
||||||
|
maxUl: number
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FiltersRulesetRow {
|
export interface FiltersRulesetRow {
|
||||||
@@ -137,10 +146,29 @@ export interface FiltersRulesetRow {
|
|||||||
rules: FilterRule[]
|
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 pingScore = Math.max(0, 100 - pingMs * 0.6)
|
||||||
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
const speedScore = Math.min(100, (dlMbps + ulMbps) / 18)
|
||||||
const w = pw / 100
|
const w = pingWeight / 100
|
||||||
return Math.round(w * pingScore + (1 - w) * speedScore)
|
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(
|
function legPing(
|
||||||
a: number | null,
|
a: number | null,
|
||||||
b: number | null,
|
b: number | null,
|
||||||
@@ -202,7 +269,10 @@ export function mapApiServersToTopology(rows: OptimizerApiServer[]): {
|
|||||||
country: s.country || "UN",
|
country: s.country || "UN",
|
||||||
model: s.model ?? "—",
|
model: s.model ?? "—",
|
||||||
ip: s.host,
|
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
|
const jumpHosts: JumpHost[] = rows
|
||||||
@@ -236,6 +306,7 @@ export function buildLiveOptimizerData(
|
|||||||
rows: OptimizerApiServer[],
|
rows: OptimizerApiServer[],
|
||||||
rulesets: FiltersRulesetRow[] | null,
|
rulesets: FiltersRulesetRow[] | null,
|
||||||
settings: OptimizerSettings,
|
settings: OptimizerSettings,
|
||||||
|
speedProbes: RouteOptimizerSpeedProbe[] = [],
|
||||||
): OptimizerData {
|
): OptimizerData {
|
||||||
const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows)
|
const { homes, jumpHosts, exitNodes, byId } = mapApiServersToTopology(rows)
|
||||||
const pw = settings.pingWeight
|
const pw = settings.pingWeight
|
||||||
@@ -255,13 +326,142 @@ export function buildLiveOptimizerData(
|
|||||||
const homeLat = homeRow?.latency ?? null
|
const homeLat = homeRow?.latency ?? null
|
||||||
|
|
||||||
const wanJhLegs: WanJhLeg[] = []
|
const wanJhLegs: WanJhLeg[] = []
|
||||||
for (const wan of home.wans) {
|
const probesForHome = speedProbes
|
||||||
for (const jh of jumpHosts) {
|
.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 jhRow = byId.get(jh.id)
|
||||||
const jhOnline = jhRow?.status === "online"
|
const jhOnline = jhRow?.status === "online"
|
||||||
const ping = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
const probe = assignedByWanId.get(wan.id)
|
||||||
const { dl, ul } = legBandwidthMbps(ping, wan.maxDl, wan.maxUl)
|
const modelPing = legPing(homeLat, jhRow?.latency ?? null, homeOnline, !!jhOnline)
|
||||||
const loss = !homeOnline || !jhOnline ? 100 : 0
|
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({
|
wanJhLegs.push({
|
||||||
wanId: wan.id,
|
wanId: wan.id,
|
||||||
jhId: jh.id,
|
jhId: jh.id,
|
||||||
@@ -269,7 +469,7 @@ export function buildLiveOptimizerData(
|
|||||||
dlMbps: dl,
|
dlMbps: dl,
|
||||||
ulMbps: ul,
|
ulMbps: ul,
|
||||||
loss,
|
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 jhRow = byId.get(jh.id)
|
||||||
const exRow = byId.get(ex.id)
|
const exRow = byId.get(ex.id)
|
||||||
const ok = jhRow?.status === "online" && exRow?.status === "online"
|
const ok = jhRow?.status === "online" && exRow?.status === "online"
|
||||||
const ping = legPing(jhRow?.latency ?? null, exRow?.latency ?? null, !!ok, !!ok)
|
const probe = pickProbeForJhExit(jh.id, ex.id)
|
||||||
const { dl, ul } = legBandwidthMbps(ping, 1000, 1000)
|
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}`, {
|
jhExMap.set(`${jh.id}::${ex.id}`, {
|
||||||
jhId: jh.id,
|
jhId: jh.id,
|
||||||
exitId: ex.id,
|
exitId: ex.id,
|
||||||
@@ -302,7 +512,7 @@ export function buildLiveOptimizerData(
|
|||||||
const totalPing = hw.pingMs + je.pingMs
|
const totalPing = hw.pingMs + je.pingMs
|
||||||
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
const dl = Math.min(hw.dlMbps, je.dlMbps)
|
||||||
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
const ul = Math.min(hw.ulMbps, je.ulMbps)
|
||||||
const score = calcScore(totalPing, dl, ul, pw)
|
const score = calcRouteScore(totalPing, dl, ul, pw)
|
||||||
fullRoutes.push({
|
fullRoutes.push({
|
||||||
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`,
|
||||||
homeId: home.id,
|
homeId: home.id,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
-6
@@ -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.
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user