Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c8ec4a02 |
@@ -952,10 +952,6 @@ export default function TrafficPage() {
|
|||||||
setFlowAnalytics(null)
|
setFlowAnalytics(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (range === "5m") {
|
|
||||||
setFlowAnalytics(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (range === "30d") {
|
if (range === "30d") {
|
||||||
const month = new Date().toISOString().slice(0, 7)
|
const month = new Date().toISOString().slice(0, 7)
|
||||||
void getFlowMonthly(backendUrl, {
|
void getFlowMonthly(backendUrl, {
|
||||||
@@ -1128,7 +1124,7 @@ export default function TrafficPage() {
|
|||||||
const ingestLine = flowIngestLine(flowStats)
|
const ingestLine = flowIngestLine(flowStats)
|
||||||
const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
|
const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
|
||||||
const flowError = liveError
|
const flowError = liveError
|
||||||
|| (flowLiveError && !(collectorAlive && /live HTTP 500/.test(flowLiveError)) ? flowLiveError : null)
|
|| flowLiveError
|
||||||
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||||
|
|
||||||
const flowKpiItems = [
|
const flowKpiItems = [
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (!abort.signal.aborted) {
|
while (!abort.signal.aborted) {
|
||||||
const payload = safeBuildLiveFlowSample(liveQuery)
|
const payload = await safeBuildLiveFlowSample(liveQuery)
|
||||||
writeSse(reply.raw, payload.event, payload.data)
|
writeSse(reply.raw, payload.event, payload.data)
|
||||||
await sleep(LIVE_TICK_MS, abort.signal)
|
await sleep(LIVE_TICK_MS, abort.signal)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,29 @@ import {
|
|||||||
seedRipeCacheForTests,
|
seedRipeCacheForTests,
|
||||||
} from "./traffic-flow-ripe.js"
|
} from "./traffic-flow-ripe.js"
|
||||||
|
|
||||||
|
{
|
||||||
|
const liveErr = await formatLiveSseFromBuilder(() => {
|
||||||
|
throw new Error("SQLITE_BUSY")
|
||||||
|
})
|
||||||
|
assert.equal(liveErr.event, "error")
|
||||||
|
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
|
||||||
|
const liveOk = await formatLiveSseFromBuilder(() => ({ ok: true }))
|
||||||
|
assert.equal(liveOk.event, "sample")
|
||||||
|
const liveAsync = await formatLiveSseFromBuilder(async () => ({
|
||||||
|
uniqueSrc: 3,
|
||||||
|
destinations: [{ id: "8.8.8.8", label: "8.8.8.8", bytes: 1, packets: 1, bps: 1, percent: 100 }],
|
||||||
|
}))
|
||||||
|
assert.equal(liveAsync.event, "sample")
|
||||||
|
assert.notEqual(JSON.stringify(liveAsync.data), "{}")
|
||||||
|
assert.equal((liveAsync.data as { uniqueSrc: number }).uniqueSrc, 3)
|
||||||
|
assert.ok(Array.isArray((liveAsync.data as { destinations: unknown[] }).destinations))
|
||||||
|
const liveReject = await formatLiveSseFromBuilder(async () => {
|
||||||
|
throw new Error("pg down")
|
||||||
|
})
|
||||||
|
assert.equal(liveReject.event, "error")
|
||||||
|
assert.equal((liveReject.data as { error: string }).error, "pg down")
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await withPgOrSkip())) {
|
if (!(await withPgOrSkip())) {
|
||||||
console.log("traffic-flow-analytics.test.ts: skip")
|
console.log("traffic-flow-analytics.test.ts: skip")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
@@ -248,13 +271,6 @@ try {
|
|||||||
assert.equal(degraded.degraded, true)
|
assert.equal(degraded.degraded, true)
|
||||||
assert.equal(degraded.conversationsList.length, 0)
|
assert.equal(degraded.conversationsList.length, 0)
|
||||||
assert.ok((degraded.bytes ?? 0) >= 12_000)
|
assert.ok((degraded.bytes ?? 0) >= 12_000)
|
||||||
const liveErr = formatLiveSseFromBuilder(() => {
|
|
||||||
throw new Error("SQLITE_BUSY")
|
|
||||||
})
|
|
||||||
assert.equal(liveErr.event, "error")
|
|
||||||
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
|
|
||||||
const liveOk = formatLiveSseFromBuilder(() => ({ ok: true }))
|
|
||||||
assert.equal(liveOk.event, "sample")
|
|
||||||
const exporters = await listFlowExporters(5)
|
const exporters = await listFlowExporters(5)
|
||||||
const clients = await listFlowClients(5)
|
const clients = await listFlowClients(5)
|
||||||
assert.ok(Array.isArray(exporters.exporters))
|
assert.ok(Array.isArray(exporters.exporters))
|
||||||
|
|||||||
@@ -599,9 +599,12 @@ export async function listFlowClients(minutes: number): Promise<{ clients: { id:
|
|||||||
return { clients }
|
return { clients }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLiveSseFromBuilder(build: () => unknown): { event: "sample" | "error"; data: unknown } {
|
export async function formatLiveSseFromBuilder(
|
||||||
|
build: () => unknown | Promise<unknown>,
|
||||||
|
): Promise<{ event: "sample" | "error"; data: unknown }> {
|
||||||
try {
|
try {
|
||||||
return { event: "sample", data: build() }
|
const data = await build()
|
||||||
|
return { event: "sample", data }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
return { event: "error", data: { error: message } }
|
return { event: "error", data: { error: message } }
|
||||||
@@ -613,10 +616,10 @@ export function isFlowAnalyticsDegraded(): boolean {
|
|||||||
return health.pendingSize >= LIVE_DEGRADED_PENDING
|
return health.pendingSize >= LIVE_DEGRADED_PENDING
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): {
|
export async function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): Promise<{
|
||||||
event: "sample" | "error"
|
event: "sample" | "error"
|
||||||
data: unknown
|
data: unknown
|
||||||
} {
|
}> {
|
||||||
return formatLiveSseFromBuilder(async () => {
|
return formatLiveSseFromBuilder(async () => {
|
||||||
const skipHeavy = isFlowAnalyticsDegraded()
|
const skipHeavy = isFlowAnalyticsDegraded()
|
||||||
return await buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
|
return await buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function stopListener(): void {
|
|||||||
clearInterval(flushTimer)
|
clearInterval(flushTimer)
|
||||||
flushTimer = null
|
flushTimer = null
|
||||||
}
|
}
|
||||||
void flushPending().catch((e) => {
|
void flushPending({ force: true }).catch((e) => {
|
||||||
setEngineError(e instanceof Error ? e.message : String(e))
|
setEngineError(e instanceof Error ? e.message : String(e))
|
||||||
})
|
})
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
|||||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
|
|
||||||
export const TICK_MS = 2_000
|
export const TICK_MS = 2_000
|
||||||
|
export const PERSIST_MS = 10_000
|
||||||
|
export const STATS_PERSIST_MS = 15_000
|
||||||
export const RING_LEN = 60
|
export const RING_LEN = 60
|
||||||
export const MAX_PENDING = 50_000
|
export const MAX_PENDING = 50_000
|
||||||
export const DAILY_ASN_TOP = 500
|
export const DAILY_ASN_TOP = 500
|
||||||
@@ -53,6 +55,42 @@ function inetOrNull(value: string | null | undefined): string | null {
|
|||||||
return s.length > 0 ? s : null
|
return s.length > 0 ? s : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidFlowInet(value: string): boolean {
|
||||||
|
const s = value.trim()
|
||||||
|
if (!s) return false
|
||||||
|
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
|
||||||
|
if (v4) {
|
||||||
|
return v4.slice(1).every((octet) => {
|
||||||
|
const n = Number(octet)
|
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 255
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!s.includes(":")) return false
|
||||||
|
if (!/^[0-9a-fA-F:]+$/.test(s)) return false
|
||||||
|
const parts = s.split(":")
|
||||||
|
if (parts.length < 3 || parts.length > 8) return false
|
||||||
|
return parts.every((p) => p.length <= 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampProto(n: number): number {
|
||||||
|
if (!Number.isFinite(n)) return 0
|
||||||
|
return Math.max(0, Math.min(255, Math.trunc(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
|
||||||
|
const src = (r.src || "").trim() || "0.0.0.0"
|
||||||
|
const dst = (r.dst || "").trim() || "0.0.0.0"
|
||||||
|
if (!isValidFlowInet(src) || !isValidFlowInet(dst)) return null
|
||||||
|
const next = inetOrNull(r.nextHop)
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
src,
|
||||||
|
dst,
|
||||||
|
nextHop: next && isValidFlowInet(next) ? next : "",
|
||||||
|
proto: clampProto(r.proto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function flowUpsertParams(r: PendingFlowRow) {
|
function flowUpsertParams(r: PendingFlowRow) {
|
||||||
return {
|
return {
|
||||||
serverId: r.serverId,
|
serverId: r.serverId,
|
||||||
@@ -129,6 +167,9 @@ let rowsStored = 0
|
|||||||
let lastFlushUsedTransaction = false
|
let lastFlushUsedTransaction = false
|
||||||
let lastPruneAt = 0
|
let lastPruneAt = 0
|
||||||
let lastPassiveCheckpointAt = 0
|
let lastPassiveCheckpointAt = 0
|
||||||
|
let lastPersistAt = 0
|
||||||
|
let lastFlushedMinute = ""
|
||||||
|
let lastStatsPersistAt = 0
|
||||||
let dataEpoch = 0
|
let dataEpoch = 0
|
||||||
let lastPersistedStats: {
|
let lastPersistedStats: {
|
||||||
packetsReceived: number
|
packetsReceived: number
|
||||||
@@ -473,7 +514,7 @@ export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; ou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistListenerStats(): Promise<boolean> {
|
async function persistListenerStats(force = false): Promise<boolean> {
|
||||||
if (
|
if (
|
||||||
lastPersistedStats
|
lastPersistedStats
|
||||||
&& lastPersistedStats.packetsReceived === packetsReceived
|
&& lastPersistedStats.packetsReceived === packetsReceived
|
||||||
@@ -483,6 +524,12 @@ async function persistListenerStats(): Promise<boolean> {
|
|||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const errorChanged = lastPersistedStats?.lastError !== lastError
|
||||||
|
const exporterChanged = lastPersistedStats?.lastExporterIp !== lastExporterIp
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && !errorChanged && !exporterChanged && now - lastStatsPersistAt < STATS_PERSIST_MS) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
await dbQuery(`
|
await dbQuery(`
|
||||||
UPDATE traffic_flow_settings
|
UPDATE traffic_flow_settings
|
||||||
SET packets_received = @packetsReceived,
|
SET packets_received = @packetsReceived,
|
||||||
@@ -504,6 +551,7 @@ async function persistListenerStats(): Promise<boolean> {
|
|||||||
lastExporterIp,
|
lastExporterIp,
|
||||||
lastError,
|
lastError,
|
||||||
}
|
}
|
||||||
|
lastStatsPersistAt = now
|
||||||
invalidateTrafficFlowSettingsCache()
|
invalidateTrafficFlowSettingsCache()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -668,20 +716,70 @@ function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function flushPending(): Promise<void> {
|
function persistDue(force: boolean, hasWork: boolean): boolean {
|
||||||
pruneRecent()
|
if (force) return true
|
||||||
rollFlowRings()
|
if (!hasWork) return false
|
||||||
await persistListenerStats()
|
if (minuteBucketIso() !== lastFlushedMinute) return true
|
||||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
return Date.now() - lastPersistAt >= PERSIST_MS
|
||||||
await pruneStored()
|
}
|
||||||
lastFlushUsedTransaction = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const rows = topNPending([...pending.values()].map(toPendingRow))
|
|
||||||
pending.clear()
|
|
||||||
for (const row of rows) mergeInto(recent, row)
|
|
||||||
|
|
||||||
const upsertSql = `
|
async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
|
||||||
|
if (rows.length === 0) return
|
||||||
|
const days = new Set(rows.map((r) => r.bucketAt))
|
||||||
|
for (const bucketAt of days) await ensureParentPartition("flow_buckets", bucketAt)
|
||||||
|
await pool.query({
|
||||||
|
text: `
|
||||||
|
INSERT INTO flow_buckets (
|
||||||
|
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM UNNEST(
|
||||||
|
$1::bigint[],
|
||||||
|
$2::timestamptz[],
|
||||||
|
$3::inet[],
|
||||||
|
$4::inet[],
|
||||||
|
$5::smallint[],
|
||||||
|
$6::int[],
|
||||||
|
$7::int[],
|
||||||
|
$8::bigint[],
|
||||||
|
$9::bigint[],
|
||||||
|
$10::text[],
|
||||||
|
$11::text[],
|
||||||
|
$12::inet[],
|
||||||
|
$13::bigint[],
|
||||||
|
$14::bigint[]
|
||||||
|
) AS t(server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms)
|
||||||
|
ON CONFLICT (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
DO UPDATE SET
|
||||||
|
bytes = flow_buckets.bytes + excluded.bytes,
|
||||||
|
packets = flow_buckets.packets + excluded.packets,
|
||||||
|
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE flow_buckets.out_iface END,
|
||||||
|
next_hop = COALESCE(excluded.next_hop, flow_buckets.next_hop),
|
||||||
|
flow_start_ms = CASE
|
||||||
|
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
|
||||||
|
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
|
||||||
|
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
|
||||||
|
`,
|
||||||
|
values: [
|
||||||
|
rows.map((r) => r.serverId),
|
||||||
|
rows.map((r) => r.bucketAt),
|
||||||
|
rows.map((r) => r.src),
|
||||||
|
rows.map((r) => r.dst),
|
||||||
|
rows.map((r) => r.proto),
|
||||||
|
rows.map((r) => r.srcPort),
|
||||||
|
rows.map((r) => r.dstPort),
|
||||||
|
rows.map((r) => r.bytes),
|
||||||
|
rows.map((r) => r.packets),
|
||||||
|
rows.map((r) => r.inIface),
|
||||||
|
rows.map((r) => r.outIface),
|
||||||
|
rows.map((r) => inetOrNull(r.nextHop)),
|
||||||
|
rows.map((r) => r.flowStartMs),
|
||||||
|
rows.map((r) => r.flowEndMs),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOW_UPSERT_SQL = `
|
||||||
INSERT INTO flow_buckets (
|
INSERT INTO flow_buckets (
|
||||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@@ -698,25 +796,87 @@ export async function flushPending(): Promise<void> {
|
|||||||
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
|
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
|
||||||
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
|
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
|
||||||
`
|
`
|
||||||
lastFlushUsedTransaction = false
|
|
||||||
|
async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
|
||||||
|
if (rows.length === 0) return 0
|
||||||
try {
|
try {
|
||||||
for (const r of rows) {
|
await upsertFlowBucketsBatch(rows)
|
||||||
await ensureParentPartition("flow_buckets", r.bucketAt)
|
return rows.length
|
||||||
await dbQuery(upsertSql, flowUpsertParams(r))
|
} catch (err) {
|
||||||
}
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
lastFlushUsedTransaction = true
|
setEngineError(`flow_buckets: ${message}`)
|
||||||
rowsStored += rows.length
|
let stored = 0
|
||||||
bumpDataEpoch()
|
|
||||||
} catch {
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
await ensureParentPartition("flow_buckets", r.bucketAt)
|
await ensureParentPartition("flow_buckets", r.bucketAt)
|
||||||
await dbQuery(upsertSql, flowUpsertParams(r))
|
await dbQuery(FLOW_UPSERT_SQL, flowUpsertParams(r))
|
||||||
rowsStored += 1
|
stored += 1
|
||||||
} catch {
|
} catch (rowErr) {
|
||||||
/* ignore single-row failures */
|
if (stored === 0 && lastError.startsWith("flow_buckets:")) {
|
||||||
|
const rowMsg = rowErr instanceof Error ? rowErr.message : String(rowErr)
|
||||||
|
setEngineError(`flow_buckets: ${rowMsg}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushPending(opts?: { force?: boolean }): Promise<void> {
|
||||||
|
pruneRecent()
|
||||||
|
rollFlowRings()
|
||||||
|
const force = Boolean(opts?.force)
|
||||||
|
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0
|
||||||
|
const due = persistDue(force, hasWork)
|
||||||
|
try {
|
||||||
|
await persistListenerStats(force)
|
||||||
|
} catch {
|
||||||
|
/* settings row may be absent in unit tests */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasWork) {
|
||||||
|
if (force) {
|
||||||
|
try {
|
||||||
|
await pruneStored()
|
||||||
|
} catch {
|
||||||
|
/* prune best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastFlushUsedTransaction = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!due) {
|
||||||
|
lastFlushUsedTransaction = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: PendingFlowRow[] = []
|
||||||
|
let skippedInet = 0
|
||||||
|
for (const row of topNPending([...pending.values()].map(toPendingRow))) {
|
||||||
|
const clean = sanitizeFlowRow(row)
|
||||||
|
if (!clean) {
|
||||||
|
skippedInet += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sanitized.push(clean)
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
for (const row of sanitized) mergeInto(recent, row)
|
||||||
|
if (skippedInet > 0) {
|
||||||
|
setEngineError(`flow_buckets: пропуск ${skippedInet} строк с невалидным IP`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFlushUsedTransaction = false
|
||||||
|
lastPersistAt = Date.now()
|
||||||
|
lastFlushedMinute = minuteBucketIso()
|
||||||
|
try {
|
||||||
|
const stored = await upsertFlowBuckets(sanitized)
|
||||||
|
lastFlushUsedTransaction = stored === sanitized.length
|
||||||
|
rowsStored += stored
|
||||||
|
if (stored > 0) bumpDataEpoch()
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
setEngineError(`flow_buckets: ${message}`)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await upsertMinuteAndDaily()
|
await upsertMinuteAndDaily()
|
||||||
@@ -724,7 +884,11 @@ export async function flushPending(): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
/* rollup best-effort */
|
/* rollup best-effort */
|
||||||
}
|
}
|
||||||
await pruneStored()
|
try {
|
||||||
|
await pruneStored()
|
||||||
|
} catch {
|
||||||
|
/* prune best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastFlushUsedTransactionForTests(): boolean {
|
export function lastFlushUsedTransactionForTests(): boolean {
|
||||||
@@ -732,7 +896,7 @@ export function lastFlushUsedTransactionForTests(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function flushPendingForTests(): Promise<void> {
|
export async function flushPendingForTests(): Promise<void> {
|
||||||
await flushPending()
|
await flushPending({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onEngineTick(): void {
|
export function onEngineTick(): void {
|
||||||
@@ -760,6 +924,9 @@ export function resetEngineForTests(): void {
|
|||||||
lastFlushUsedTransaction = false
|
lastFlushUsedTransaction = false
|
||||||
lastPruneAt = 0
|
lastPruneAt = 0
|
||||||
lastPassiveCheckpointAt = Date.now()
|
lastPassiveCheckpointAt = Date.now()
|
||||||
|
lastPersistAt = 0
|
||||||
|
lastFlushedMinute = ""
|
||||||
|
lastStatsPersistAt = 0
|
||||||
lastPersistedStats = null
|
lastPersistedStats = null
|
||||||
bumpDataEpoch()
|
bumpDataEpoch()
|
||||||
pendingCap = MAX_PENDING
|
pendingCap = MAX_PENDING
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
setWantListenForTests,
|
setWantListenForTests,
|
||||||
simulateWorkerExitForTests,
|
simulateWorkerExitForTests,
|
||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
|
import { configureEngine, droppedForTests, getEngineStats, isValidFlowInet, pendingSizeForTests } from "./traffic-flow-engine.js"
|
||||||
import { dbQuery } from "../db/index.js"
|
import { dbQuery } from "../db/index.js"
|
||||||
import { withPgOrSkip } from "../test/pg.js"
|
import { withPgOrSkip } from "../test/pg.js"
|
||||||
|
|
||||||
@@ -69,6 +69,27 @@ assert.equal(droppedForTests(), 3)
|
|||||||
assert.equal(peekPendingFlows().length, 3)
|
assert.equal(peekPendingFlows().length, 3)
|
||||||
setPendingCapForTests(null)
|
setPendingCapForTests(null)
|
||||||
|
|
||||||
|
assert.equal(isValidFlowInet("10.0.0.1"), true)
|
||||||
|
assert.equal(isValidFlowInet("8.8.8.8"), true)
|
||||||
|
assert.equal(isValidFlowInet("0:0:0:0:0:0:0:1"), true)
|
||||||
|
assert.equal(isValidFlowInet("not-an-ip"), false)
|
||||||
|
assert.equal(isValidFlowInet("999.1.1.1"), false)
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
ingestParsedFlowsForServerForTests(1, [{
|
||||||
|
src: "not-an-ip",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 1,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 10,
|
||||||
|
packets: 1,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "",
|
||||||
|
}])
|
||||||
|
await flushPendingForTests()
|
||||||
|
assert.match(getEngineStats().lastError, /невалидн/)
|
||||||
|
|
||||||
resetFlowRingsForTests()
|
resetFlowRingsForTests()
|
||||||
configureEngine({ topN: 20 })
|
configureEngine({ topN: 20 })
|
||||||
const talkers = Array.from({ length: 25 }, (_, i) => ({
|
const talkers = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export async function startTrafficFlowListener() {
|
|||||||
export function stopTrafficFlowListener() {
|
export function stopTrafficFlowListener() {
|
||||||
wantListen = false
|
wantListen = false
|
||||||
stopWorkerProcess()
|
stopWorkerProcess()
|
||||||
void flushPending().catch(() => { /* ignore */ })
|
void flushPending({ force: true }).catch(() => { /* ignore */ })
|
||||||
state = { bound: false, address: null }
|
state = { bound: false, address: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ export async function ingestParsedFlowsForTests(exporterIp: string, flows: Parse
|
|||||||
if (serverId == null) return
|
if (serverId == null) return
|
||||||
queueParsedFlows(serverId, flows)
|
queueParsedFlows(serverId, flows)
|
||||||
rollFlowRings()
|
rollFlowRings()
|
||||||
await flushPending()
|
await flushPending({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
||||||
@@ -444,7 +444,7 @@ export function lastFlushUsedTransactionForTests(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function flushPendingForTests(): Promise<void> {
|
export async function flushPendingForTests(): Promise<void> {
|
||||||
await flushPending()
|
await flushPending({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tableCount(name: string): Promise<number> {
|
async function tableCount(name: string): Promise<number> {
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ services:
|
|||||||
- io_workers=3
|
- io_workers=3
|
||||||
- -c
|
- -c
|
||||||
- effective_io_concurrency=200
|
- effective_io_concurrency=200
|
||||||
|
- -c
|
||||||
|
- max_wal_size=2GB
|
||||||
|
- -c
|
||||||
|
- checkpoint_timeout=15min
|
||||||
|
- -c
|
||||||
|
- checkpoint_completion_target=0.9
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
- mmapp-pgdata:/var/lib/postgresql
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ services:
|
|||||||
- io_workers=3
|
- io_workers=3
|
||||||
- -c
|
- -c
|
||||||
- effective_io_concurrency=200
|
- effective_io_concurrency=200
|
||||||
|
- -c
|
||||||
|
- max_wal_size=2GB
|
||||||
|
- -c
|
||||||
|
- checkpoint_timeout=15min
|
||||||
|
- -c
|
||||||
|
- checkpoint_completion_target=0.9
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
- mmapp-pgdata:/var/lib/postgresql
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ services:
|
|||||||
- io_workers=3
|
- io_workers=3
|
||||||
- -c
|
- -c
|
||||||
- effective_io_concurrency=200
|
- effective_io_concurrency=200
|
||||||
|
- -c
|
||||||
|
- max_wal_size=2GB
|
||||||
|
- -c
|
||||||
|
- checkpoint_timeout=15min
|
||||||
|
- -c
|
||||||
|
- checkpoint_completion_target=0.9
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
- mmapp-pgdata:/var/lib/postgresql
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ services:
|
|||||||
- io_workers=3
|
- io_workers=3
|
||||||
- -c
|
- -c
|
||||||
- effective_io_concurrency=200
|
- effective_io_concurrency=200
|
||||||
|
- -c
|
||||||
|
- max_wal_size=2GB
|
||||||
|
- -c
|
||||||
|
- checkpoint_timeout=15min
|
||||||
|
- -c
|
||||||
|
- checkpoint_completion_target=0.9
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
- mmapp-pgdata:/var/lib/postgresql
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ services:
|
|||||||
- io_workers=3
|
- io_workers=3
|
||||||
- -c
|
- -c
|
||||||
- effective_io_concurrency=200
|
- effective_io_concurrency=200
|
||||||
|
- -c
|
||||||
|
- max_wal_size=2GB
|
||||||
|
- -c
|
||||||
|
- checkpoint_timeout=15min
|
||||||
|
- -c
|
||||||
|
- checkpoint_completion_target=0.9
|
||||||
volumes:
|
volumes:
|
||||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
- mmapp-pgdata:/var/lib/postgresql
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
|
|||||||
@@ -16,3 +16,8 @@ effective_io_concurrency = 200
|
|||||||
# TOAST: lz4 (не zstd — для колонок только pglz/lz4)
|
# TOAST: lz4 (не zstd — для колонок только pglz/lz4)
|
||||||
default_toast_compression = lz4
|
default_toast_compression = lz4
|
||||||
wal_compression = lz4
|
wal_compression = lz4
|
||||||
|
|
||||||
|
# Реже checkpoint / меньше full-page writes (NetFlow upsert)
|
||||||
|
max_wal_size = 2GB
|
||||||
|
checkpoint_timeout = 15min
|
||||||
|
checkpoint_completion_target = 0.9
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,12 @@ function parseSseBlock(block: string): { event: string; data: string } {
|
|||||||
return { event, data: dataLines.join("\n") }
|
return { event, data: dataLines.join("\n") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidFlowLiveSample(value: unknown): value is FlowAnalyticsDto {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
const v = value as Partial<FlowAnalyticsDto>
|
||||||
|
return typeof v.uniqueSrc === "number" && Array.isArray(v.destinations)
|
||||||
|
}
|
||||||
|
|
||||||
export function useFlowLive(opts: {
|
export function useFlowLive(opts: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
backendUrl: string
|
backendUrl: string
|
||||||
@@ -75,7 +81,11 @@ export function useFlowLive(opts: {
|
|||||||
if (!raw.trim() || raw.trim().startsWith(":")) continue
|
if (!raw.trim() || raw.trim().startsWith(":")) continue
|
||||||
const ev = parseSseBlock(raw)
|
const ev = parseSseBlock(raw)
|
||||||
if (ev.event === "sample" && ev.data) {
|
if (ev.event === "sample" && ev.data) {
|
||||||
const parsed = JSON.parse(ev.data) as FlowAnalyticsDto
|
const parsed = JSON.parse(ev.data) as unknown
|
||||||
|
if (!isValidFlowLiveSample(parsed)) {
|
||||||
|
setError("live sample пустой")
|
||||||
|
continue
|
||||||
|
}
|
||||||
setSample(parsed)
|
setSample(parsed)
|
||||||
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||||
} else if (ev.event === "error" && ev.data) {
|
} else if (ev.event === "error" && ev.data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user