Init commit
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import http from "node:http"
|
||||
import https from "node:https"
|
||||
import type { Server } from "../db/schema.js"
|
||||
import type {
|
||||
RosIdentity, RosInterface, RosIpAddress, RosResource,
|
||||
RosBgpSession,
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
RosBfdSession,
|
||||
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
|
||||
} from "../types/server.js"
|
||||
|
||||
// ── connection params ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface MikrotikConnectParams {
|
||||
host: string
|
||||
port: number
|
||||
useSsl: boolean
|
||||
verifySsl: boolean
|
||||
username: string
|
||||
password: string
|
||||
apiPath?: string // defaults to "/rest"
|
||||
}
|
||||
|
||||
// ── low-level HTTP helpers ────────────────────────────────────────────────────
|
||||
|
||||
function rosRequest(
|
||||
params: MikrotikConnectParams,
|
||||
path: 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 options: https.RequestOptions = {
|
||||
hostname: params.host,
|
||||
port: params.port,
|
||||
path: basePath + path,
|
||||
method: "GET",
|
||||
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
||||
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 body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body += chunk })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, body))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from RouterOS: ${body.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function rosPost(
|
||||
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: "POST",
|
||||
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
|
||||
}
|
||||
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.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function rosDelete(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const basePath = params.apiPath ?? "/rest"
|
||||
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: params.host,
|
||||
port: params.port,
|
||||
path: basePath + path,
|
||||
method: "DELETE",
|
||||
headers: { Authorization: authHeader, "Content-Type": "application/json" },
|
||||
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 body = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { body += chunk })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, body)); return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
constructor(private readonly params: MikrotikConnectParams) {}
|
||||
|
||||
/** Convenience factory from a DB Server row */
|
||||
static fromServer(server: Server): MikrotikClient {
|
||||
return new MikrotikClient({
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
useSsl: server.useSsl,
|
||||
verifySsl: server.verifySsl,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
})
|
||||
}
|
||||
|
||||
async get<T>(path: string, timeoutMs = 10_000): Promise<T> {
|
||||
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 delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||
return rosDelete(this.params, path, timeoutMs)
|
||||
}
|
||||
|
||||
// ── typed helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async getIdentity(): Promise<RosIdentity> {
|
||||
return this.get<RosIdentity>("/system/identity")
|
||||
}
|
||||
|
||||
async getResource(): Promise<RosResource> {
|
||||
return this.get<RosResource>("/system/resource")
|
||||
}
|
||||
|
||||
async getInterfaces(): Promise<RosInterface[]> {
|
||||
return this.get<RosInterface[]>("/interface")
|
||||
}
|
||||
|
||||
async getIpAddresses(): Promise<RosIpAddress[]> {
|
||||
return this.get<RosIpAddress[]>("/ip/address")
|
||||
}
|
||||
|
||||
async getBgpSessions(): Promise<RosBgpSession[]> {
|
||||
return this.get<RosBgpSession[]>("/routing/bgp/session")
|
||||
}
|
||||
|
||||
/** Returns the raw (un-typed) BGP session objects — used for debugging */
|
||||
async getBgpSessionsRaw(): Promise<unknown[]> {
|
||||
return this.get<unknown[]>("/routing/bgp/session")
|
||||
}
|
||||
|
||||
// ── OSPF ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async getOspfNeighbors(): Promise<RosOspfNeighbor[]> {
|
||||
return this.get<RosOspfNeighbor[]>("/routing/ospf/neighbor")
|
||||
}
|
||||
|
||||
async getOspfAreas(): Promise<RosOspfArea[]> {
|
||||
return this.get<RosOspfArea[]>("/routing/ospf/area")
|
||||
}
|
||||
|
||||
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
|
||||
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
|
||||
}
|
||||
|
||||
async getOspfInstances(): Promise<RosOspfInstance[]> {
|
||||
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
|
||||
}
|
||||
|
||||
async getBfdSessions(): Promise<RosBfdSession[]> {
|
||||
return this.get<RosBfdSession[]>("/routing/bfd/session")
|
||||
}
|
||||
|
||||
// ── extra endpoints for exec route ────────────────────────────────────────
|
||||
|
||||
async getIpRoutes(): Promise<RosIpRoute[]> {
|
||||
return this.get<RosIpRoute[]>("/ip/route")
|
||||
}
|
||||
|
||||
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
||||
}
|
||||
|
||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
|
||||
}
|
||||
|
||||
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
|
||||
const body: Record<string, string> = {
|
||||
address,
|
||||
count: String(count),
|
||||
interval: "0.2s",
|
||||
}
|
||||
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
|
||||
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
|
||||
}
|
||||
|
||||
async bandwidthTest(params: {
|
||||
address: string
|
||||
user: string
|
||||
password: string
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: number
|
||||
}): Promise<Array<Record<string, string>>> {
|
||||
const body: Record<string, string> = {
|
||||
address: params.address,
|
||||
user: params.user,
|
||||
password: params.password,
|
||||
protocol: params.protocol ?? "tcp",
|
||||
direction: params.direction ?? "both",
|
||||
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
|
||||
}
|
||||
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error type ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly path: string,
|
||||
public readonly body: string,
|
||||
) {
|
||||
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
|
||||
this.name = "MikrotikError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { SnapshotInsert } from "../db/schema.js"
|
||||
import type { SnapshotRead } from "../types/server.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Connect to a RouterOS device, collect system info, persist a snapshot,
|
||||
* and sync the server's display name from system/identity.
|
||||
*/
|
||||
export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
||||
const server = db
|
||||
.select()
|
||||
.from(servers)
|
||||
.where(eq(servers.id, serverId))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`Server with id=${serverId} not found`)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const t0 = performance.now()
|
||||
|
||||
const partialSnap: Partial<SnapshotInsert> = {
|
||||
serverId,
|
||||
polledAt: now,
|
||||
status: "offline",
|
||||
latencyMs: null,
|
||||
}
|
||||
|
||||
try {
|
||||
// Fire all requests in parallel for speed
|
||||
const [identity, resource, ifaces, addresses] = await Promise.all([
|
||||
client.getIdentity(),
|
||||
client.getResource(),
|
||||
client.getInterfaces(),
|
||||
client.getIpAddresses(),
|
||||
])
|
||||
|
||||
const latencyMs = performance.now() - t0
|
||||
|
||||
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
|
||||
const cpuLoad = parseInt(resource["cpu-load"], 10)
|
||||
const freeMem = parseInt(resource["free-memory"], 10)
|
||||
const totalMem = parseInt(resource["total-memory"], 10)
|
||||
|
||||
Object.assign(partialSnap, {
|
||||
status: "online",
|
||||
latencyMs,
|
||||
identityName: identity.name,
|
||||
rosVersion: resource["version"],
|
||||
boardName: resource["board-name"],
|
||||
uptime: resource["uptime"],
|
||||
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
|
||||
freeMemory: isNaN(freeMem) ? null : freeMem,
|
||||
totalMemory: isNaN(totalMem) ? null : totalMem,
|
||||
rawInterfaces: JSON.stringify(ifaces),
|
||||
rawIpAddresses: JSON.stringify(addresses),
|
||||
} satisfies Partial<SnapshotInsert>)
|
||||
|
||||
// Keep server.name in sync with RouterOS identity
|
||||
db.update(servers)
|
||||
.set({ name: identity.name, updatedAt: now })
|
||||
.where(eq(servers.id, serverId))
|
||||
.run()
|
||||
|
||||
} catch (err) {
|
||||
// Log but don't throw — we still persist the offline snapshot
|
||||
console.warn(`[poller] server id=${serverId} unreachable:`, (err as Error).message)
|
||||
}
|
||||
|
||||
const [inserted] = db
|
||||
.insert(serverSnapshots)
|
||||
.values(partialSnap as SnapshotInsert)
|
||||
.returning()
|
||||
.all()
|
||||
|
||||
return toSnapshotRead(inserted)
|
||||
}
|
||||
|
||||
// ── helper ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function toSnapshotRead(s: typeof serverSnapshots.$inferSelect): SnapshotRead {
|
||||
return {
|
||||
id: s.id,
|
||||
serverId: s.serverId,
|
||||
polledAt: s.polledAt,
|
||||
status: s.status,
|
||||
latencyMs: s.latencyMs ?? null,
|
||||
rosVersion: s.rosVersion ?? null,
|
||||
boardName: s.boardName ?? null,
|
||||
uptime: s.uptime ?? null,
|
||||
cpuLoad: s.cpuLoad ?? null,
|
||||
freeMemory: s.freeMemory ?? null,
|
||||
totalMemory: s.totalMemory ?? null,
|
||||
identityName: s.identityName ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
interface RosIfaceTraffic {
|
||||
name?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
"rx-byte"?: string
|
||||
"tx-byte"?: string
|
||||
"rx-bits-per-second"?: string
|
||||
"tx-bits-per-second"?: string
|
||||
}
|
||||
|
||||
export interface TrafficCollectorState {
|
||||
running: boolean
|
||||
lastRunAt: string | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let collecting = false
|
||||
const state: TrafficCollectorState = {
|
||||
running: false,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
}
|
||||
|
||||
function toNum(raw: unknown): number {
|
||||
const n = Number.parseFloat(String(raw ?? "0"))
|
||||
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0
|
||||
}
|
||||
|
||||
function getSettingsRow() {
|
||||
const row = db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = new Date().toISOString()
|
||||
db.insert(trafficSettings).values({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
intervalSec: 30,
|
||||
retentionDays: 14,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
function cleanupOldSamples(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
db.delete(trafficSamples)
|
||||
.where(lt(trafficSamples.sampledAt, cutoff))
|
||||
.run()
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<void> {
|
||||
if (collecting) return
|
||||
collecting = true
|
||||
const startedAt = Date.now()
|
||||
const now = new Date().toISOString()
|
||||
const settings = getSettingsRow()
|
||||
|
||||
try {
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
for (const srv of enabledServers) {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||
if (ifaces.length === 0) continue
|
||||
db.insert(trafficSamples).values(
|
||||
ifaces.map((i) => ({
|
||||
serverId: srv.id,
|
||||
interfaceName: i.name ?? "unknown",
|
||||
sampledAt: now,
|
||||
rxBytes: toNum(i["rx-byte"]),
|
||||
txBytes: toNum(i["tx-byte"]),
|
||||
rxBps: toNum(i["rx-bits-per-second"]),
|
||||
txBps: toNum(i["tx-bits-per-second"]),
|
||||
running: (i.running ?? "false") === "true",
|
||||
disabled: (i.disabled ?? "false") === "true",
|
||||
})),
|
||||
).run()
|
||||
} catch {
|
||||
// Continue collecting from remaining servers
|
||||
}
|
||||
}
|
||||
|
||||
cleanupOldSamples(Math.max(1, settings.retentionDays))
|
||||
db.update(trafficSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - startedAt,
|
||||
lastError: "",
|
||||
updatedAt: now,
|
||||
}).where(eq(trafficSettings.id, 1)).run()
|
||||
|
||||
state.lastRunAt = now
|
||||
state.lastError = null
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
db.update(trafficSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - startedAt,
|
||||
lastError: msg,
|
||||
updatedAt: now,
|
||||
}).where(eq(trafficSettings.id, 1)).run()
|
||||
state.lastRunAt = now
|
||||
state.lastError = msg
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTrafficCollector() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
state.running = false
|
||||
}
|
||||
|
||||
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 {
|
||||
return { ...state }
|
||||
}
|
||||
|
||||
export function getTrafficSettings() {
|
||||
return getSettingsRow()
|
||||
}
|
||||
|
||||
export function updateTrafficSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
retentionDays?: number
|
||||
}) {
|
||||
const prev = getSettingsRow()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
|
||||
restartTrafficCollector()
|
||||
return getSettingsRow()
|
||||
}
|
||||
|
||||
export function readServerSamplesInRange(serverId: number, sinceIso: string) {
|
||||
return db.select()
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
gte(trafficSamples.sampledAt, sinceIso),
|
||||
))
|
||||
.orderBy(asc(trafficSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
servers,
|
||||
uptimeProbeSamples,
|
||||
uptimeProbes,
|
||||
uptimeResourceSamples,
|
||||
uptimeSettings,
|
||||
} from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let collecting = false
|
||||
|
||||
function parseDurationToSeconds(raw: string | undefined): number {
|
||||
if (!raw) return 0
|
||||
let total = 0
|
||||
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
switch (m[2]) {
|
||||
case "w": total += n * 604800; break
|
||||
case "d": total += n * 86400; break
|
||||
case "h": total += n * 3600; break
|
||||
case "m": total += n * 60; break
|
||||
case "s": total += n; break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function toNum(raw: unknown): number {
|
||||
const n = Number.parseInt(String(raw ?? "0"), 10)
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// HH:MM:SS(.sss) from some RouterOS outputs
|
||||
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
|
||||
}
|
||||
|
||||
const n = Number.parseFloat(s)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
|
||||
if (row) return row
|
||||
const now = new Date().toISOString()
|
||||
db.insert(uptimeSettings).values({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
intervalSec: 15,
|
||||
retentionDays: 14,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
return db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
|
||||
}
|
||||
|
||||
function cleanup(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff)).run()
|
||||
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")
|
||||
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 avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
|
||||
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
|
||||
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
|
||||
return { avgRtt, loss, status }
|
||||
}
|
||||
|
||||
export async function collectUptimeOnce(): Promise<void> {
|
||||
if (collecting) return
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const now = new Date().toISOString()
|
||||
const settings = getSettings()
|
||||
|
||||
try {
|
||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
|
||||
for (const s of enabledServers) {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(s)
|
||||
const resource = await client.getResource()
|
||||
db.insert(uptimeResourceSamples).values({
|
||||
serverId: s.id,
|
||||
sampledAt: now,
|
||||
status: "online",
|
||||
cpuLoad: toNum(resource["cpu-load"]),
|
||||
freeMemory: toNum(resource["free-memory"]),
|
||||
totalMemory: toNum(resource["total-memory"]),
|
||||
freeHddSpace: toNum(resource["free-hdd-space"]),
|
||||
totalHddSpace: toNum(resource["total-hdd-space"]),
|
||||
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
|
||||
boardName: String(resource["board-name"] ?? ""),
|
||||
rosVersion: String(resource["version"] ?? ""),
|
||||
}).run()
|
||||
} catch {
|
||||
db.insert(uptimeResourceSamples).values({
|
||||
serverId: s.id,
|
||||
sampledAt: now,
|
||||
status: "offline",
|
||||
cpuLoad: 0,
|
||||
freeMemory: 0,
|
||||
totalMemory: 0,
|
||||
freeHddSpace: 0,
|
||||
totalHddSpace: 0,
|
||||
uptimeSeconds: 0,
|
||||
boardName: "",
|
||||
rosVersion: "",
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
|
||||
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||
for (const p of probes) {
|
||||
const src = enabledServers.find((s) => s.id === p.srcServerId)
|
||||
if (!src) continue
|
||||
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()
|
||||
} catch {
|
||||
db.insert(uptimeProbeSamples).values({
|
||||
probeId: p.id,
|
||||
sampledAt: now,
|
||||
rttMs: null,
|
||||
lossPct: 100,
|
||||
status: "down",
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(Math.max(1, settings.retentionDays))
|
||||
db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: "",
|
||||
updatedAt: now,
|
||||
}).where(eq(uptimeSettings.id, 1)).run()
|
||||
} catch (e) {
|
||||
db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: e instanceof Error ? e.message : String(e),
|
||||
updatedAt: now,
|
||||
}).where(eq(uptimeSettings.id, 1)).run()
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
|
||||
export function restartUptimeCollector() {
|
||||
const s = getSettings()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
if (!s.enabled) return
|
||||
const intervalMs = Math.max(5, s.intervalSec) * 1000
|
||||
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
|
||||
}
|
||||
|
||||
export function stopUptimeCollector() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
export function readUptimeSettings() {
|
||||
return getSettings()
|
||||
}
|
||||
|
||||
export function updateUptimeSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
retentionDays?: number
|
||||
}) {
|
||||
const prev = getSettings()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
retentionDays: patch.retentionDays ?? prev.retentionDays,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
|
||||
restartUptimeCollector()
|
||||
return readUptimeSettings()
|
||||
}
|
||||
|
||||
export function readProbeRows() {
|
||||
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
|
||||
}
|
||||
|
||||
export function replaceProbes(rows: Array<{
|
||||
id: string
|
||||
srcServerId: number
|
||||
srcInterface: string
|
||||
name: string
|
||||
target: string
|
||||
probeFilter: string
|
||||
enabled: 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()
|
||||
}
|
||||
|
||||
export function readProbeSamplesSince(sinceIso: string) {
|
||||
return db.select().from(uptimeProbeSamples)
|
||||
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
|
||||
.orderBy(asc(uptimeProbeSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function readResourceSamplesSince(sinceIso: string, serverId: number) {
|
||||
return db.select().from(uptimeResourceSamples)
|
||||
.where(and(
|
||||
eq(uptimeResourceSamples.serverId, serverId),
|
||||
gte(uptimeResourceSamples.sampledAt, sinceIso),
|
||||
))
|
||||
.orderBy(asc(uptimeResourceSamples.sampledAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user