Init 2
This commit is contained in:
@@ -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,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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user