feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import {
|
||||
MikrotikClient,
|
||||
firewallRestPath,
|
||||
} from "./mikrotik.js"
|
||||
import type {
|
||||
FirewallFamily,
|
||||
FirewallTable,
|
||||
RosFirewallAddressList,
|
||||
RosFirewallFilter,
|
||||
} from "../types/server.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
export interface FirewallRuleDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
table: FirewallTable
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
hits: number
|
||||
log: boolean
|
||||
logPrefix: string
|
||||
tlsHost?: string
|
||||
layer7Proto?: string
|
||||
}
|
||||
|
||||
export interface FirewallAddressListDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
list: string
|
||||
address: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
|
||||
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
|
||||
|
||||
function dash(v: string | undefined): string {
|
||||
const s = v?.trim() ?? ""
|
||||
return s.length > 0 ? s : "—"
|
||||
}
|
||||
|
||||
function rosDisabled(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
function parseHits(raw: RosFirewallFilter): number {
|
||||
const n = Number.parseInt(raw.packets ?? "0", 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export function ruleUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:${table}:${rosId}`
|
||||
}
|
||||
|
||||
export function addressUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:address-list:${rosId}`
|
||||
}
|
||||
|
||||
export function mapFirewallRule(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
raw: RosFirewallFilter,
|
||||
idx: number,
|
||||
): FirewallRuleDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
const src = raw["src-address"] || raw["src-address-list"]
|
||||
const dst = raw["dst-address"] || raw["dst-address-list"]
|
||||
const port = raw["dst-port"] || raw["src-port"]
|
||||
const iface = raw["in-interface"] || raw["out-interface"]
|
||||
return {
|
||||
id: ruleUiId(server.id, family, table, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
table,
|
||||
chain: raw.chain || "",
|
||||
action: raw.action || "",
|
||||
proto: raw.protocol || "all",
|
||||
src: dash(src),
|
||||
dst: dash(dst),
|
||||
port: dash(port),
|
||||
iface: dash(iface),
|
||||
comment: raw.comment ?? "",
|
||||
enabled: !rosDisabled(raw.disabled),
|
||||
hits: parseHits(raw),
|
||||
log: raw.log === "true" || raw.log === "yes",
|
||||
logPrefix: raw["log-prefix"] ?? "",
|
||||
tlsHost: raw["tls-host"],
|
||||
layer7Proto: raw["layer7-protocol"],
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAddressList(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
raw: RosFirewallAddressList,
|
||||
idx: number,
|
||||
): FirewallAddressListDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
return {
|
||||
id: addressUiId(server.id, family, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
list: raw.list || "",
|
||||
address: raw.address || "",
|
||||
comment: raw.comment ?? "",
|
||||
disabled: rosDisabled(raw.disabled),
|
||||
timeout: raw.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
|
||||
try {
|
||||
const rows = await fn()
|
||||
return Array.isArray(rows) ? rows : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServerFirewall(server: ServerRow): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const ruleJobs = FAMILIES.flatMap((family) =>
|
||||
TABLES.map(async (table) => {
|
||||
const raw = await safeGet(() => client.getFirewallRules(family, table))
|
||||
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
|
||||
}),
|
||||
)
|
||||
const listJobs = FAMILIES.map(async (family) => {
|
||||
const raw = await safeGet(() => client.getFirewallAddressList(family))
|
||||
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
|
||||
})
|
||||
const [ruleChunks, listChunks] = await Promise.all([
|
||||
Promise.all(ruleJobs),
|
||||
Promise.all(listJobs),
|
||||
])
|
||||
return {
|
||||
rules: ruleChunks.flat(),
|
||||
addressLists: listChunks.flat(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFirewallAll(): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
return await fetchServerFirewall(server)
|
||||
} catch {
|
||||
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rules: perServer.flatMap((r) => r.rules),
|
||||
addressLists: perServer.flatMap((r) => r.addressLists),
|
||||
}
|
||||
}
|
||||
|
||||
export { firewallRestPath, FAMILIES, TABLES }
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
RosBgpSession,
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
RosBfdSession,
|
||||
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
|
||||
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
|
||||
FirewallFamily, FirewallTable,
|
||||
} from "../types/server.js"
|
||||
|
||||
// ── connection params ─────────────────────────────────────────────────────────
|
||||
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
export function firewallRestPath(
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable | "address-list",
|
||||
): string {
|
||||
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
|
||||
return `${root}/${table}`
|
||||
}
|
||||
|
||||
export function encodeRosId(rosId: string): string {
|
||||
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||
return encodeURIComponent(id)
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
@@ -461,7 +475,15 @@ export class MikrotikClient {
|
||||
}
|
||||
|
||||
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
||||
return this.getFirewallRules("ip", "filter")
|
||||
}
|
||||
|
||||
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
|
||||
}
|
||||
|
||||
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
|
||||
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
|
||||
}
|
||||
|
||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
||||
|
||||
interface RosIfaceTraffic {
|
||||
name?: string
|
||||
@@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) {
|
||||
.run()
|
||||
}
|
||||
|
||||
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
|
||||
const last = db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (!last) return new Map()
|
||||
const rows = db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
rxBytes: trafficSamples.rxBytes,
|
||||
txBytes: trafficSamples.txBytes,
|
||||
sampledAt: trafficSamples.sampledAt,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||
))
|
||||
.all()
|
||||
return new Map(rows.map((r) => [r.interfaceName, r]))
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
if (collecting) {
|
||||
@@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||
let sumRx = 0
|
||||
let sumTx = 0
|
||||
for (const i of ifaces) {
|
||||
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
||||
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
||||
}
|
||||
if (ifaces.length > 0) {
|
||||
db.insert(trafficSamples).values(
|
||||
ifaces.map((i) => ({
|
||||
serverId: srv.id,
|
||||
interfaceName: i.name ?? "unknown",
|
||||
sampledAt: now,
|
||||
rxBytes: toNum(i["rx-byte"]),
|
||||
txBytes: toNum(i["tx-byte"]),
|
||||
rxBps: toNum(i["rx-bits-per-second"]),
|
||||
txBps: toNum(i["tx-bits-per-second"]),
|
||||
running: (i.running ?? "false") === "true",
|
||||
disabled: (i.disabled ?? "false") === "true",
|
||||
})),
|
||||
).run()
|
||||
const prevWave = readPreviousWave(srv.id)
|
||||
const nowMs = Date.parse(now)
|
||||
let sumRxMbps = 0
|
||||
let sumTxMbps = 0
|
||||
const rows = ifaces.map((i) => {
|
||||
const interfaceName = i.name ?? "unknown"
|
||||
const rxBytes = toNum(i["rx-byte"])
|
||||
const txBytes = toNum(i["tx-byte"])
|
||||
const running = (i.running ?? "false") === "true"
|
||||
const disabled = (i.disabled ?? "false") === "true"
|
||||
const prev = prevWave.get(interfaceName)
|
||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||
const rxBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const txBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||
sumRxMbps += bpsToMbps(rxBps)
|
||||
sumTxMbps += bpsToMbps(txBps)
|
||||
}
|
||||
return {
|
||||
serverId: srv.id,
|
||||
interfaceName,
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
}
|
||||
})
|
||||
if (rows.length > 0) {
|
||||
db.insert(trafficSamples).values(rows).run()
|
||||
}
|
||||
snapshot.servers.push({
|
||||
serverId: srv.id,
|
||||
@@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
host: srv.host,
|
||||
ok: true,
|
||||
interfaces: ifaces.length,
|
||||
sumRxMbps: Math.round(sumRx),
|
||||
sumTxMbps: Math.round(sumTx),
|
||||
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
|
||||
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
|
||||
})
|
||||
} catch (err) {
|
||||
snapshot.servers.push({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bpsToMbps,
|
||||
bucketAvg,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
shouldIncludeIface,
|
||||
type TrafficSampleLike,
|
||||
} from "./traffic-rate.js"
|
||||
|
||||
assert.equal(isLoopbackName("lo"), true)
|
||||
assert.equal(isLoopbackName("loopback"), true)
|
||||
assert.equal(isLoopbackName("ether1"), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, false), true)
|
||||
assert.equal(shouldIncludeIface("ether1", false, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, true), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
|
||||
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
|
||||
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
|
||||
assert.equal(bpsToMbps(1_500_000), 1.5)
|
||||
assert.equal(bpsToMbps(400_000), 0.4)
|
||||
|
||||
const buckets = bucketAvg(
|
||||
[
|
||||
{ t: 0, v: 10 },
|
||||
{ t: 1000, v: 20 },
|
||||
],
|
||||
0,
|
||||
1000,
|
||||
4,
|
||||
)
|
||||
assert.equal(buckets.length, 4)
|
||||
assert.equal(buckets[0], 10)
|
||||
assert.equal(buckets[3], 20)
|
||||
assert.equal(buckets[1], 0)
|
||||
assert.equal(buckets[2], 0)
|
||||
|
||||
const t0 = "2026-09-06T10:00:00.000Z"
|
||||
const t1 = "2026-09-06T10:00:30.000Z"
|
||||
const t2 = "2026-09-06T10:01:00.000Z"
|
||||
const start = Date.parse(t0)
|
||||
const end = Date.parse(t2)
|
||||
|
||||
const samples: TrafficSampleLike[] = [
|
||||
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
|
||||
const built = buildTrafficFromSamples(samples, start, end)
|
||||
assert.ok(built.rxPeak > 0, "peak RX from delta")
|
||||
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
|
||||
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
|
||||
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
|
||||
|
||||
const live = parseMonitorTraffic([
|
||||
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
|
||||
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
|
||||
])
|
||||
assert.equal(live.rxMbps, 2)
|
||||
assert.equal(live.txMbps, 0.5)
|
||||
|
||||
const onceOnly = parseMonitorTraffic(
|
||||
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
|
||||
{ onlyInterface: "ether1" },
|
||||
)
|
||||
assert.equal(onceOnly.rxMbps, 1)
|
||||
|
||||
console.log("traffic-rate tests ok")
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
|
||||
|
||||
export const SERIES_POINTS = 60
|
||||
|
||||
export interface TrafficSampleLike {
|
||||
interfaceName: string
|
||||
sampledAt: string
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
rxBps: number
|
||||
txBps: number
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RatePoint {
|
||||
t: number
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
}
|
||||
|
||||
export interface BuiltTrafficSeries {
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotalGiB: number
|
||||
txTotalGiB: number
|
||||
sessions: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
export interface MonitorLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
}
|
||||
|
||||
export function isLoopbackName(name: string): boolean {
|
||||
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
|
||||
}
|
||||
|
||||
export function shouldIncludeIface(
|
||||
name: string,
|
||||
running: boolean,
|
||||
disabled: boolean,
|
||||
onlyInterface?: string,
|
||||
): boolean {
|
||||
if (onlyInterface) return name === onlyInterface
|
||||
if (isLoopbackName(name)) return false
|
||||
return running && !disabled
|
||||
}
|
||||
|
||||
export function bpsToMbps(bps: number): number {
|
||||
if (!Number.isFinite(bps) || bps <= 0) return 0
|
||||
return Math.round((bps / 1_000_000) * 1000) / 1000
|
||||
}
|
||||
|
||||
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
|
||||
export function rateBpsFromDelta(
|
||||
prevBytes: number,
|
||||
nextBytes: number,
|
||||
prevAtMs: number,
|
||||
nextAtMs: number,
|
||||
): number | null {
|
||||
const dtSec = (nextAtMs - prevAtMs) / 1000
|
||||
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
|
||||
if (nextBytes < prevBytes) return null
|
||||
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
|
||||
}
|
||||
|
||||
export function bucketAvg(
|
||||
points: Array<{ t: number; v: number }>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
target = SERIES_POINTS,
|
||||
): number[] {
|
||||
const buckets = Array.from({ length: target }, () => 0)
|
||||
const counts = Array.from({ length: target }, () => 0)
|
||||
const span = rangeEndMs - rangeStartMs
|
||||
if (span <= 0 || points.length === 0) return buckets
|
||||
for (const p of points) {
|
||||
const ratio = (p.t - rangeStartMs) / span
|
||||
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
|
||||
buckets[i] += p.v
|
||||
counts[i] += 1
|
||||
}
|
||||
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
|
||||
}
|
||||
|
||||
function parseIsoMs(iso: string): number {
|
||||
const t = Date.parse(iso)
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
|
||||
export function buildTrafficFromSamples(
|
||||
rows: TrafficSampleLike[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
if (rows.length === 0) return empty
|
||||
|
||||
const byIface = new Map<string, TrafficSampleLike[]>()
|
||||
for (const r of rows) {
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const rxPoints: Array<{ t: number; v: number }> = []
|
||||
const txPoints: Array<{ t: number; v: number }> = []
|
||||
const byTs = new Map<number, { rx: number; tx: number }>()
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const [name, arr] of byIface) {
|
||||
if (onlyInterface) {
|
||||
if (name !== onlyInterface) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (!last) continue
|
||||
if (!onlyInterface && (!last.running || last.disabled)) continue
|
||||
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
|
||||
const first = sorted[0]
|
||||
if (first) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
}
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const cur = sorted[i]
|
||||
if (!prev || !cur) continue
|
||||
if (!onlyInterface && (!cur.running || cur.disabled)) continue
|
||||
const t0 = parseIsoMs(prev.sampledAt)
|
||||
const t1 = parseIsoMs(cur.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
|
||||
if (rxBps == null && txBps == null) continue
|
||||
const rxMbps = bpsToMbps(rxBps ?? 0)
|
||||
const txMbps = bpsToMbps(txBps ?? 0)
|
||||
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
|
||||
acc.rx += rxMbps
|
||||
acc.tx += txMbps
|
||||
byTs.set(t1, acc)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [t, v] of byTs) {
|
||||
rxPoints.push({ t, v: v.rx })
|
||||
txPoints.push({ t, v: v.tx })
|
||||
}
|
||||
|
||||
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
||||
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
||||
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
|
||||
return {
|
||||
rxNow: last?.rx ?? 0,
|
||||
txNow: last?.tx ?? 0,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMonitorTraffic(
|
||||
raw: unknown,
|
||||
opts?: { onlyInterface?: string },
|
||||
): MonitorLiveSample {
|
||||
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
|
||||
let rxBps = 0
|
||||
let txBps = 0
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const rec = item as Record<string, unknown>
|
||||
const name = String(rec.name ?? rec.interface ?? "")
|
||||
if (opts?.onlyInterface) {
|
||||
if (name && name !== opts.onlyInterface) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
|
||||
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
|
||||
}
|
||||
return {
|
||||
rxMbps: bpsToMbps(rxBps),
|
||||
txMbps: bpsToMbps(txBps),
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user