This commit is contained in:
Denozordec
2026-05-03 11:16:07 +07:00
parent ce00c4c671
commit bdb9b72fac
66 changed files with 9553 additions and 1547 deletions
+53 -16
View File
@@ -79,6 +79,7 @@ function rosPost(
path: string,
body: Record<string, string>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
@@ -100,25 +101,55 @@ function rosPost(
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)
let req: ReturnType<typeof lib.request> | undefined
const req = lib.request(options, (res) => {
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
if (req) {
req.setTimeout(0)
req.removeListener("timeout", onSocketTimeout)
}
externalSignal?.removeEventListener("abort", onExternalAbort)
fn()
}
const onExternalAbort = () => {
req?.destroy(new DOMException("Aborted", "AbortError"))
}
const onSocketTimeout = () => {
req?.destroy()
settle(() => reject(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)))
}
if (externalSignal?.aborted) {
settle(() => reject(new DOMException("Aborted", "AbortError")))
return
}
externalSignal?.addEventListener("abort", onExternalAbort)
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
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
settle(() => {
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
})
req.on("error", (err) => { clearTimeout(timer); reject(err) })
req.setTimeout(timeoutMs)
req.on("timeout", onSocketTimeout)
req.on("error", (err) => {
settle(() => reject(err))
})
req.write(payload)
req.end()
})
@@ -189,8 +220,8 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
@@ -260,12 +291,18 @@ export class MikrotikClient {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
async ping(
address: string,
count = 4,
interfaceName?: string,
options?: { interval?: string },
): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
interval: "0.2s",
}
if (options?.interval && options.interval.trim()) body.interval = options.interval.trim()
else body.interval = "0.2s"
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { desc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
+70 -23
View File
@@ -65,6 +65,8 @@ function getSettings() {
id: 1,
enabled: true,
intervalSec: 15,
probeIntervalSec: 15,
speedIntervalSec: 60,
retentionDays: 14,
createdAt: now,
updatedAt: now,
@@ -78,18 +80,31 @@ function cleanup(retentionDays: number) {
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
}
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
const lossRaw = sum?.["packet-loss"] ?? "100%"
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
export function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string; "avg-rtt"?: string }>) {
const sum = [...results].reverse().find((r) =>
r.sent != null || r.received != null || r["packet-loss"] != null || r["avg-rtt"] != null,
)
const okReplies = results.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout")
const timedOutReplies = results.filter((r) => String(r.status ?? "").toLowerCase() === "timeout")
const rtts = okReplies
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
const avgFromSummary = parsePingTimeMs(sum?.time)
const avgFromSummary = parsePingTimeMs(sum?.time) ?? parsePingTimeMs(sum?.["avg-rtt"])
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
const sent = Number.parseInt(String(sum?.sent ?? ""), 10)
const received = Number.parseInt(String(sum?.received ?? ""), 10)
const sentFallback = okReplies.length + timedOutReplies.length
const safeSent = Number.isFinite(sent) && sent > 0 ? sent : sentFallback
const safeReceived = Number.isFinite(received) && received >= 0 ? received : okReplies.length
const computedLoss = safeSent > 0 ? Math.round(((safeSent - safeReceived) / safeSent) * 100) : null
const lossRaw = String(sum?.["packet-loss"] ?? "")
const parsedLoss = Number.parseInt(lossRaw.replace("%", ""), 10)
const lossSource = Number.isFinite(parsedLoss) ? parsedLoss : computedLoss
const loss =
typeof lossSource === "number" && Number.isFinite(lossSource)
? Math.max(0, Math.min(100, lossSource))
: 100
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
return { avgRtt, loss, status }
}
@@ -188,7 +203,7 @@ export function restartUptimeCollector() {
if (timer) clearInterval(timer)
timer = null
if (!s.enabled) return
const intervalMs = Math.max(5, s.intervalSec) * 1000
const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
}
@@ -204,12 +219,17 @@ export function readUptimeSettings() {
export function updateUptimeSettings(patch: {
enabled?: boolean
intervalSec?: number
probeIntervalSec?: number
speedIntervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const nextProbeInterval = patch.probeIntervalSec ?? patch.intervalSec ?? prev.probeIntervalSec ?? prev.intervalSec
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
intervalSec: nextProbeInterval,
probeIntervalSec: nextProbeInterval,
speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
@@ -222,6 +242,15 @@ export function readProbeRows() {
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
}
export function updateProbeShowOnDashboard(probeId: string, showOnDashboard: boolean): boolean {
const now = new Date().toISOString()
const r = db.update(uptimeProbes)
.set({ showOnDashboard, updatedAt: now })
.where(eq(uptimeProbes.id, probeId))
.run()
return (r.changes ?? 0) > 0
}
export function replaceProbes(rows: Array<{
id: string
srcServerId: number
@@ -230,22 +259,40 @@ export function replaceProbes(rows: Array<{
target: string
probeFilter: string
enabled: boolean
showOnDashboard?: boolean
}>) {
db.delete(uptimeProbes).run()
if (rows.length === 0) return
const now = new Date().toISOString()
db.insert(uptimeProbes).values(rows.map((r, i) => ({
id: r.id,
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
sortOrder: i,
createdAt: now,
updatedAt: now,
}))).run()
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
const nextIds = new Set(rows.map((r) => r.id))
// Remove only probes that user actually deleted (their samples are removed by FK cascade).
for (const p of existing) {
if (nextIds.has(p.id)) continue
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
}
// Upsert rows in-place to preserve sample history for unchanged probe ids.
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i]
const patch = {
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
showOnDashboard: r.showOnDashboard ?? false,
sortOrder: i,
updatedAt: now,
}
const updated = db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).run()
if ((updated.changes ?? 0) > 0) continue
db.insert(uptimeProbes).values({
id: r.id,
...patch,
createdAt: now,
}).run()
}
}
export function readProbeSamplesSince(sinceIso: string) {