feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s

При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-08 01:36:48 +07:00
co-authored by Cursor
parent 0e5fb065e2
commit ec43591a99
128 changed files with 4304 additions and 3639 deletions
+9 -13
View File
@@ -85,15 +85,11 @@ async function deleteTxtRecord(token: string, zoneId: string, recordId: string):
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
}
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] {
if (!raw?.trim()) return []
try {
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
} catch {
return []
}
function parseWanUplinks(raw: unknown): WanUplinkRow[] {
const data = Array.isArray(raw) ? raw : typeof raw === "string" && raw.trim()
? (() => { try { const p = JSON.parse(raw) as unknown; return Array.isArray(p) ? p : [] } catch { return [] } })()
: []
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
}
function normalizeIpv4(raw: string | undefined): string | null {
@@ -217,10 +213,10 @@ async function sleep(ms: number) {
}
async function getOrCreateAccountKey(): Promise<Buffer> {
const existing = getAcmeAccountPrivateKey()
const existing = await getAcmeAccountPrivateKey()
if (existing) return Buffer.from(existing)
const key = await acme.crypto.createPrivateKey()
saveAcmeAccountPrivateKey(key.toString("utf8"))
await saveAcmeAccountPrivateKey(key.toString("utf8"))
return key
}
@@ -247,8 +243,8 @@ export async function issueCertificateWithCloudflareDns(params: {
trustStore: string[]
onStep?: (step: string) => void
}): Promise<void> {
const settings = getAcmeSettingsPublic()
const token = getAcmeCloudflareToken()
const settings = await getAcmeSettingsPublic()
const token = await getAcmeCloudflareToken()
if (!token) throw new Error("Не настроен Cloudflare API token")
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
@@ -1,5 +1,5 @@
/**
* Внутренние хуки: после записи сигналов в SQLite (коллекторы) — отложенный прогон `alert_engine`.
* Внутренние хуки: после записи сигналов в PostgreSQL (коллекторы) — отложенный прогон `alert_engine`.
* Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`.
*
* Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать
@@ -15,7 +15,7 @@ let alertAfterCollectChain: Promise<void> = Promise.resolve()
let alertEngineRunner: (() => Promise<void>) | null = null
/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */
/** Задать runner один раз при старте приложения (обычно `() => await executeSchedulerJob("alert_engine")`). */
export function wireAlertEngineRunner(run: () => Promise<void>): void {
alertEngineRunner = run
}
@@ -39,7 +39,7 @@ async function runAlertEngineWhenIdle(): Promise<void> {
/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */
export function scheduleAlertEngineAfterDataCollectors(): void {
if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer)
alertAfterCollectTimer = setTimeout(() => {
alertAfterCollectTimer = setTimeout(async () => {
alertAfterCollectTimer = null
alertAfterCollectChain = alertAfterCollectChain
.then(() => runAlertEngineWhenIdle())
@@ -4,11 +4,10 @@ import { db } from "../../db/index.js"
import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js"
import { withPgOrSkip } from "../../test/pg.js"
const ruleId = "test-confirm-stability-recovery-bypass"
deleteConfirmPending(ruleId)
const baseRule: ApiAlertRule = {
id: ruleId,
name: "Server transition",
@@ -46,25 +45,31 @@ const recoveryHit = {
payloadHash: "recovery-1",
}
if (!(await withPgOrSkip())) {
console.log("alert-engine confirm-stability tests skip")
process.exit(0)
}
await deleteConfirmPending(ruleId)
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]]))
assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]]))
assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки")
}
{
const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
const m = await computeStabilityReadyMap([baseRule], new Map([[ruleId, null]]))
assert.equal(m.get(ruleId), false, "без hit отправка не готова")
const left = db
const left = (await db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
.limit(1))[0]
assert.equal(left, undefined, "pending должен очищаться после отсутствия hit")
}
@@ -4,46 +4,44 @@ import { alertEngineConfirmPending } from "../../db/schema.js"
import type { ApiAlertRule } from "../alerts-service.js"
import type { RuleEvalHit } from "./types.js"
export function deleteConfirmPending(ruleId: string) {
db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId)).run()
export async function deleteConfirmPending(ruleId: string) {
await db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId))
}
/**
* Обновляет состояние ожидания стабильности и возвращает, можно ли отправлять уведомление в этом тике.
* При отсутствии срабатывания (`hit == null`) ожидание сбрасывается.
*/
export function stabilityReadyForSend(
export async function stabilityReadyForSend(
ruleId: string,
hit: RuleEvalHit | null,
delaySec: number | null | undefined,
): boolean {
): Promise<boolean> {
if (!hit) {
deleteConfirmPending(ruleId)
await deleteConfirmPending(ruleId)
return false
}
const sec = delaySec != null && delaySec > 0 ? Math.floor(delaySec) : 0
if (sec <= 0) {
deleteConfirmPending(ruleId)
await deleteConfirmPending(ruleId)
return true
}
const now = Date.now()
const nowIso = new Date().toISOString()
const existing = db
const existing = (await db
.select()
.from(alertEngineConfirmPending)
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.limit(1)
.all()[0]
.limit(1))[0]
if (!existing || existing.payloadHash !== hit.payloadHash) {
if (existing) {
db.update(alertEngineConfirmPending)
await db.update(alertEngineConfirmPending)
.set({ payloadHash: hit.payloadHash, sinceAt: nowIso })
.where(eq(alertEngineConfirmPending.ruleId, ruleId))
.run()
} else {
db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso }).run()
await db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso })
}
return false
}
@@ -54,18 +52,18 @@ export function stabilityReadyForSend(
}
/** После успешной отправки — сбросить таймер стабильности для следующего цикла. */
export function clearConfirmPendingAfterSend(ruleIds: string[]) {
for (const id of ruleIds) deleteConfirmPending(id)
export async function clearConfirmPendingAfterSend(ruleIds: string[]) {
for (const id of ruleIds) await deleteConfirmPending(id)
}
export function computeStabilityReadyMap(
export async function computeStabilityReadyMap(
rules: ApiAlertRule[],
hitByRule: Map<string, RuleEvalHit | null>,
): Map<string, boolean> {
): Promise<Map<string, boolean>> {
const m = new Map<string, boolean>()
for (const rule of rules) {
if (!rule.enabled) {
deleteConfirmPending(rule.id)
await deleteConfirmPending(rule.id)
m.set(rule.id, false)
continue
}
@@ -76,7 +74,7 @@ export function computeStabilityReadyMap(
? rule.recoveryStabilitySec
: 0
: rule.confirmStabilitySec
m.set(rule.id, stabilityReadyForSend(rule.id, hit, delaySec))
m.set(rule.id, await stabilityReadyForSend(rule.id, hit, delaySec))
}
return m
}
@@ -73,20 +73,20 @@ export function buildRuleDiagnostics(
return out
}
export function computeDecisions(args: {
export async function computeDecisions(args: {
rules: ApiAlertRule[]
groups: ApiAlertGroup[]
hitByRule: Map<string, RuleEvalHit | null>
state: EngineStateMap
canSend: boolean
}): {
}): Promise<{
stabilityReady: Map<string, boolean>
standalone: StandaloneDecision[]
grouped: GroupDecision[]
ruleDiag: AlertEngineRuleDiag[]
} {
}> {
const { rules, groups, hitByRule, state, canSend } = args
const stabilityReady = computeStabilityReadyMap(rules, hitByRule)
const stabilityReady = await computeStabilityReadyMap(rules, hitByRule)
const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend)
const standalone: StandaloneDecision[] = []
const grouped: GroupDecision[] = []
+15 -19
View File
@@ -17,7 +17,9 @@ export type AlertOutboxPayload = {
}
}
function safeParsePayload(raw: string): AlertOutboxPayload | null {
function safeParsePayload(raw: unknown): AlertOutboxPayload | null {
if (raw && typeof raw === "object") return raw as AlertOutboxPayload
if (typeof raw !== "string") return null
try {
return JSON.parse(raw) as AlertOutboxPayload
} catch {
@@ -25,21 +27,20 @@ function safeParsePayload(raw: string): AlertOutboxPayload | null {
}
}
export function enqueueTelegramOutbox(input: {
export async function enqueueTelegramOutbox(input: {
id: string
dedupeKey: string
payload: AlertOutboxPayload
maxRetries?: number
}): boolean {
const existing = db
}): Promise<boolean> {
const existing = (await db
.select()
.from(alertOutbox)
.where(eq(alertOutbox.dedupeKey, input.dedupeKey))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing && (existing.status === "pending" || existing.status === "sent")) return false
const nowIso = new Date().toISOString()
db.insert(alertOutbox)
await db.insert(alertOutbox)
.values({
id: input.id,
dedupeKey: input.dedupeKey,
@@ -48,22 +49,20 @@ export function enqueueTelegramOutbox(input: {
retryCount: 0,
maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)),
nextAttemptAt: nowIso,
payloadJson: JSON.stringify(input.payload),
payloadJson: input.payload,
})
.run()
return true
}
export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> {
return (async () => {
const nowIso = new Date().toISOString()
const rows = db
const rows = await db
.select()
.from(alertOutbox)
.where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso)))
.orderBy(asc(alertOutbox.createdAt))
.limit(Math.max(1, limit))
.all()
let sent = 0
let failed = 0
@@ -72,27 +71,25 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
for (const row of rows) {
const payload = safeParsePayload(row.payloadJson)
if (!payload) {
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
status: "failed",
lastError: "invalid payload_json",
})
.where(eq(alertOutbox.id, row.id))
.run()
failed += 1
continue
}
const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId })
if (send.ok) {
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
status: "sent",
sentAt: new Date().toISOString(),
lastError: null,
})
.where(eq(alertOutbox.id, row.id))
.run()
appendAlertHistory({
await appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
@@ -107,7 +104,7 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
}
const nextRetry = row.retryCount + 1
const exhausted = nextRetry >= row.maxRetries
db.update(alertOutbox)
await db.update(alertOutbox)
.set({
retryCount: nextRetry,
status: exhausted ? "failed" : "pending",
@@ -115,9 +112,8 @@ export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; faile
lastError: send.error,
})
.where(eq(alertOutbox.id, row.id))
.run()
if (exhausted) {
appendAlertHistory({
await appendAlertHistory({
id: payload.history.id,
ruleId: payload.history.ruleId ?? null,
groupId: payload.history.groupId ?? null,
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm"
import { db } from "../../db/index.js"
import { parseJsonObject } from "../../db/json.js"
import { alertEnginePrevLive } from "../../db/schema.js"
export type PrevLiveKind = "gre" | "bgp"
@@ -7,42 +8,32 @@ export type PrevLiveKind = "gre" | "bgp"
/** Предыдущие строковые состояния (GRE: up|down|degraded; BGP: state как с роутера). */
export type PrevLiveStringMap = Record<string, string>
function safeParseMap(json: string | null | undefined): PrevLiveStringMap {
if (!json || json.trim() === "") return {}
try {
const v = JSON.parse(json) as unknown
if (v == null || typeof v !== "object" || Array.isArray(v)) return {}
return v as PrevLiveStringMap
} catch {
return {}
}
function safeParseMap(json: unknown): PrevLiveStringMap {
return parseJsonObject(json) as PrevLiveStringMap
}
export function loadPrevLiveMap(kind: PrevLiveKind): PrevLiveStringMap {
const row = db
export async function loadPrevLiveMap(kind: PrevLiveKind): Promise<PrevLiveStringMap> {
const row = (await db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
.limit(1))[0]
return safeParseMap(row?.payloadJson)
}
export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
const payloadJson = JSON.stringify(map)
const existing = db
export async function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
const payloadJson = map
const existing = (await db
.select()
.from(alertEnginePrevLive)
.where(eq(alertEnginePrevLive.kind, kind))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing) {
if (existing.payloadJson === payloadJson) return
db.update(alertEnginePrevLive)
await db.update(alertEnginePrevLive)
.set({ payloadJson, updatedAt: new Date().toISOString() })
.where(eq(alertEnginePrevLive.kind, kind))
.run()
return
}
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
await db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() })
}
+24 -25
View File
@@ -26,7 +26,7 @@ const SNAPSHOT_SOURCE_WAIT_MS = 30_000
const SNAPSHOT_SOURCE_POLL_MS = 20
/**
* Не строить снимок, пока коллекторы пишут в SQLite — иначе гонка с `alert_engine` по таймеру
* Не строить снимок, пока коллекторы пишут в PostgreSQL — иначе гонка с `alert_engine` по таймеру
* (в т.ч. слот планировщика занят до первого `await` в коллекторе, когда внутренний `collecting` ещё false).
*/
async function awaitSnapshotSourcesIdle(): Promise<void> {
@@ -52,8 +52,8 @@ function newOutboxId(): string {
return `ao-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHash: string | null }> {
const rows = db.select().from(alertEngineState).all()
async function loadEngineStateMap(): Promise<Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>> {
const rows = await db.select().from(alertEngineState)
const m = new Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>()
for (const r of rows) {
m.set(r.scopeKey, { lastFiredAt: r.lastFiredAt, lastPayloadHash: r.lastPayloadHash ?? null })
@@ -61,15 +61,14 @@ function loadEngineStateMap(): Map<string, { lastFiredAt: string; lastPayloadHas
return m
}
function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
const existing = db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1).all()[0]
async function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) {
const existing = (await db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1))[0]
if (existing) {
db.update(alertEngineState)
await db.update(alertEngineState)
.set({ lastFiredAt, lastPayloadHash })
.where(eq(alertEngineState.scopeKey, scopeKey))
.run()
} else {
db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash }).run()
await db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash })
}
}
@@ -90,16 +89,16 @@ function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): s
export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
const errors: string[] = []
const sampledAt = new Date().toISOString()
const latestSourceFinishedAt = getLatestSourceFinishedAt()
const prevSourceFinishedAt = getSourceWatermark()
const latestSourceFinishedAt = await getLatestSourceFinishedAt()
const prevSourceFinishedAt = await getSourceWatermark()
const hasNewSources =
latestSourceFinishedAt != null &&
(prevSourceFinishedAt == null || latestSourceFinishedAt > prevSourceFinishedAt)
const rules = listAlertRules()
const groups = listAlertGroups()
const state = loadEngineStateMap()
const pub = getTelegramPublic()
const rules = await listAlertRules()
const groups = await listAlertGroups()
const state = await loadEngineStateMap()
const pub = await getTelegramPublic()
const canSend = pub.tokenConfigured && Boolean(pub.chatId?.trim())
let standaloneFires = 0
let groupFires = 0
@@ -107,10 +106,10 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
let ruleDiag: AlertEngineRunResult["ruleDiag"] = []
if (hasNewSources) {
await awaitSnapshotSourcesIdle()
const snap = ingestAlertSignals()
const snap = await ingestAlertSignals()
const matched = matchRules(rules, snap)
errors.push(...matched.errors)
const { standalone, grouped, ruleDiag: diag } = computeDecisions({
const { standalone, grouped, ruleDiag: diag } = await computeDecisions({
rules,
groups,
hitByRule: matched.hitByRule,
@@ -123,7 +122,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
if (!canSend) break
const firedAt = new Date().toISOString()
const key = ruleScopeKey(d.rule.id, d.hit.transition)
const queued = enqueueTelegramOutbox({
const queued = await enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${d.hit.payloadHash}`,
payload: {
@@ -142,8 +141,8 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
if (!queued) continue
standaloneFires += 1
clearConfirmPendingAfterSend([d.rule.id])
upsertEngineState(key, firedAt, d.hit.payloadHash)
await clearConfirmPendingAfterSend([d.rule.id])
await upsertEngineState(key, firedAt, d.hit.payloadHash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: d.hit.payloadHash })
}
@@ -155,7 +154,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
const body = `Группа «${d.group.name}» (${d.group.combineMode === "any" ? "ANY" : "ALL"})\n\n${lines.join("\n")}`
const hash = d.hits.map((h) => h.payloadHash).sort().join("|")
const key = `group:${d.group.id}`
const queued = enqueueTelegramOutbox({
const queued = await enqueueTelegramOutbox({
id: newOutboxId(),
dedupeKey: `${key}:${hash}`,
payload: {
@@ -173,18 +172,18 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
if (!queued) continue
groupFires += 1
upsertEngineState(key, firedAt, hash)
await upsertEngineState(key, firedAt, hash)
state.set(key, { lastFiredAt: firedAt, lastPayloadHash: hash })
clearConfirmPendingAfterSend(d.members.map((r) => r.id))
await clearConfirmPendingAfterSend(d.members.map((r) => r.id))
}
updateSourceWatermark(latestSourceFinishedAt)
await updateSourceWatermark(latestSourceFinishedAt)
}
const outbox = await dispatchPendingOutbox()
errors.push(...outbox.errors)
if (standaloneFires > 0 || groupFires > 0) {
appendEvent({
await appendEvent({
level: "info",
eventType: "alerts.engine.fired",
sourceModule: "alerts",
@@ -197,7 +196,7 @@ export async function runAlertEngineOnce(): Promise<AlertEngineRunResult> {
})
}
if (errors.length > 0) {
appendEvent({
await appendEvent({
level: "warning",
eventType: "alerts.engine.errors",
sourceModule: "alerts",
@@ -1,6 +1,6 @@
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
/** Единая точка входа для подготовки сигналов к rule matching. */
export function ingestAlertSignals() {
return buildSignalSnapshotFromCollectors()
export async function ingestAlertSignals() {
return await buildSignalSnapshotFromCollectors()
}
@@ -1,4 +1,4 @@
import { sqliteDatabase } from "../../db/index.js"
import { dbAll } from "../../db/index.js"
import type {
GreBgpSnapshotRunSnapshot,
PingRunSnapshot,
@@ -20,25 +20,23 @@ function normalizeNameKey(v: string): string {
return v.trim().toLowerCase()
}
function safeParseRunSnapshot(raw: string | null): SchedulerRunSnapshot | null {
if (!raw) return null
try {
return JSON.parse(raw) as SchedulerRunSnapshot
} catch {
return null
}
function safeParseRunSnapshot(raw: unknown): SchedulerRunSnapshot | null {
const obj = typeof raw === "string" ? (() => {
try { return JSON.parse(raw) as unknown } catch { return null }
})() : raw
if (!obj || typeof obj !== "object") return null
return obj as SchedulerRunSnapshot
}
function readLatestOkRunSnapshots(jobKey: string, limit = 32): SchedulerRunSnapshot[] {
const rows = sqliteDatabase
.prepare(
`SELECT result_json AS resultJson
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
)
.all(jobKey, limit) as { resultJson: string | null }[]
async function readLatestOkRunSnapshots(jobKey: string, limit = 32): Promise<SchedulerRunSnapshot[]> {
const rows = await dbAll<{ resultJson: unknown }>(
`SELECT result_json AS "resultJson"
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
[jobKey, limit],
)
const out: SchedulerRunSnapshot[] = []
for (const row of rows) {
const parsed = safeParseRunSnapshot(row.resultJson)
@@ -227,13 +225,13 @@ function collectBgpSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): BgpPeerSi
}
/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */
export function buildSignalSnapshotFromCollectors(): SignalSnapshot {
export async function buildSignalSnapshotFromCollectors(): Promise<SignalSnapshot> {
const sampledAt = new Date().toISOString()
const resourceRuns = readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
const resourceRuns = await readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = await readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = await readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = await readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = await readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[]
const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns)
const restByKey = collectServerSignalsFromRestRuns(restRuns)
@@ -2,6 +2,6 @@ import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
import type { SignalSnapshot } from "./types.js"
/** Legacy-совместимость: единый источник сигналов — collector snapshots из scheduler_runs. */
export function buildSignalSnapshot(): SignalSnapshot {
return buildSignalSnapshotFromCollectors()
export async function buildSignalSnapshot(): Promise<SignalSnapshot> {
return await buildSignalSnapshotFromCollectors()
}
@@ -11,44 +11,40 @@ const SOURCE_JOBS = [
"gre_bgp",
] as const
export function getLatestSourceFinishedAt(): string | null {
const row = db
export async function getLatestSourceFinishedAt(): Promise<string | null> {
const row = (await db
.select({ finishedAt: schedulerRuns.finishedAt })
.from(schedulerRuns)
.where(inArray(schedulerRuns.jobKey, [...SOURCE_JOBS]))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(1)
.all()[0]
.limit(1))[0]
return row?.finishedAt ?? null
}
export function getSourceWatermark(): string | null {
const row = db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1).all()[0]
export async function getSourceWatermark(): Promise<string | null> {
const row = (await db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1))[0]
return row?.lastSourceFinishedAt ?? null
}
export function updateSourceWatermark(lastSourceFinishedAt: string | null): void {
const existing = db
export async function updateSourceWatermark(lastSourceFinishedAt: string | null): Promise<void> {
const existing = (await db
.select()
.from(alertEngineCursor)
.where(eq(alertEngineCursor.id, 1))
.limit(1)
.all()[0]
.limit(1))[0]
if (existing) {
db.update(alertEngineCursor)
await db.update(alertEngineCursor)
.set({
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.where(eq(alertEngineCursor.id, 1))
.run()
return
}
db.insert(alertEngineCursor)
await db.insert(alertEngineCursor)
.values({
id: 1,
lastSourceFinishedAt,
updatedAt: new Date().toISOString(),
})
.run()
}
+107 -116
View File
@@ -1,5 +1,5 @@
import { asc, desc, eq, sql } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import { db, dbAll } from "../db/index.js"
import {
alertGroups,
alertHistory,
@@ -55,24 +55,22 @@ export type ApiAlertHistoryEntry = {
source: "db" | "live"
}
function ensureTelegramRow() {
let row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
async function ensureTelegramRow() {
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
if (!row) {
db.insert(alertTelegramSettings).values({ id: 1 }).run()
row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0]
await db.insert(alertTelegramSettings).values({ id: 1 })
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
}
return row!
}
function lastFiredByRuleId(): Map<string, string> {
const rows = sqliteDatabase
.prepare(
`SELECT rule_id AS ruleId, MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
.all() as { ruleId: string; mx: string }[]
async function lastFiredByRuleId(): Promise<Map<string, string>> {
const rows = await dbAll<{ ruleId: string; mx: string }>(
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
@@ -80,12 +78,11 @@ function lastFiredByRuleId(): Map<string, string> {
return m
}
function targetsByRuleId(): Map<string, string[]> {
const rows = db
async function targetsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
@@ -96,12 +93,11 @@ function targetsByRuleId(): Map<string, string[]> {
return m
}
function conditionsByRuleId(): Map<string, string[]> {
const rows = db
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
.all()
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
@@ -112,26 +108,25 @@ function conditionsByRuleId(): Map<string, string[]> {
return m
}
export function listAlertGroups(): ApiAlertGroup[] {
return db
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
const rows = await db
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
.all()
.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
return rows.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
}
export function listAlertRules(): ApiAlertRule[] {
const lf = lastFiredByRuleId()
const tgtMap = targetsByRuleId()
const condMap = conditionsByRuleId()
const rows = db.select().from(alertRules).all()
export async function listAlertRules(): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId()
const tgtMap = await targetsByRuleId()
const condMap = await conditionsByRuleId()
const rows = await db.select().from(alertRules)
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
@@ -207,23 +202,23 @@ export type ReplaceAlertGroupInput = {
}
/** Атомарно заменяет группы, правила и строки targets. */
export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`(datetime('now'))`
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`now()`
const { groups, rules } = payload
db.transaction((tx) => {
tx.delete(alertRuleConditions).run()
tx.delete(alertRuleTargets).run()
tx.delete(alertRules).run()
tx.delete(alertGroups).run()
await db.transaction(async (tx) => {
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
tx.insert(alertGroups).values({
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
}).run()
})
}
for (const r of rules) {
const rawTargets =
@@ -250,7 +245,7 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
tx.insert(alertRules).values({
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
@@ -265,39 +260,39 @@ export function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[];
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
}).run()
rawTargets.forEach((t, i) => {
tx.insert(alertRuleTargets).values({
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
}).run()
})
rawConds.forEach((c, i) => {
tx.insert(alertRuleConditions).values({
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
}).run()
})
})
}
}
})
}
/** Совместимость: только правила, группы не трогаем. */
export function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = listAlertGroups()
replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = await listAlertGroups()
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
}
export function getTelegramSettingsRow() {
return ensureTelegramRow()
export async function getTelegramSettingsRow() {
return await ensureTelegramRow()
}
export function getTelegramPublic() {
const row = ensureTelegramRow()
export async function getTelegramPublic() {
const row = await ensureTelegramRow()
const tid = row.messageThreadId
const messageThreadId =
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
@@ -308,13 +303,13 @@ export function getTelegramPublic() {
}
}
export function updateTelegramSettings(patch: {
export async function updateTelegramSettings(patch: {
token?: string | null
chatId?: string | undefined
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = ensureTelegramRow()
const cur = await ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
@@ -335,27 +330,26 @@ export function updateTelegramSettings(patch: {
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`(datetime('now'))`,
updatedAt: sql`now()`,
}
if (nextThread !== undefined) {
db.update(alertTelegramSettings)
await db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
.run()
} else {
db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1)).run()
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
}
return getTelegramPublic()
return await getTelegramPublic()
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
export function getTelegramBotToken(): string {
return ensureTelegramRow().botToken?.trim() ?? ""
export async function getTelegramBotToken(): Promise<string> {
return (await ensureTelegramRow()).botToken?.trim() ?? ""
}
/** Для `sendMessage`: только если в БД задана валидная тема. */
export function getTelegramMessageThreadIdForApi(): number | undefined {
const { messageThreadId } = getTelegramPublic()
export async function getTelegramMessageThreadIdForApi(): Promise<number | undefined> {
const { messageThreadId } = await getTelegramPublic()
return messageThreadId ?? undefined
}
@@ -390,9 +384,9 @@ export async function sendTelegramAlertMessage(opts: {
/** Переопределение темы; undefined — как в настройках */
messageThreadId?: number | null
}): Promise<{ ok: true } | { ok: false; error: string }> {
const token = (opts.token?.trim() || getTelegramBotToken()).trim()
const token = (opts.token?.trim() || await getTelegramBotToken()).trim()
if (!token) return { ok: false, error: "Не задан Bot Token" }
const pub = getTelegramPublic()
const pub = await getTelegramPublic()
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
const threadId =
@@ -400,7 +394,7 @@ export async function sendTelegramAlertMessage(opts: {
? opts.messageThreadId >= 1
? Math.floor(opts.messageThreadId)
: undefined
: getTelegramMessageThreadIdForApi()
: await getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
@@ -423,7 +417,7 @@ export async function sendTelegramAlertMessage(opts: {
}
}
export function appendAlertHistory(entry: {
export async function appendAlertHistory(entry: {
id: string
ruleId?: string | null
groupId?: string | null
@@ -434,7 +428,7 @@ export function appendAlertHistory(entry: {
firedAt?: string
}) {
const firedAt = entry.firedAt ?? new Date().toISOString()
db.insert(alertHistory)
await db.insert(alertHistory)
.values({
id: entry.id,
ruleId: entry.ruleId ?? null,
@@ -445,7 +439,6 @@ export function appendAlertHistory(entry: {
sentOk: entry.sentOk,
firedAt,
})
.run()
}
interface LiveResRow {
@@ -462,38 +455,38 @@ interface LiveProbeRow {
lossPct: number | null
}
function loadLiveResourceIssues(): LiveResRow[] {
return sqliteDatabase
.prepare(
`SELECT s.name AS serverName, urs.sampled_at AS sampledAt
FROM uptime_resource_samples urs
INNER JOIN (
SELECT server_id, MAX(id) AS mid FROM uptime_resource_samples GROUP BY server_id
) latest ON latest.mid = urs.id
INNER JOIN servers s ON s.id = urs.server_id
WHERE urs.status = 'offline'`,
)
.all() as LiveResRow[]
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
return dbAll<LiveResRow>(
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
FROM uptime_resource_samples urs
INNER JOIN (
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
FROM uptime_resource_samples
ORDER BY server_id, sampled_at DESC
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
INNER JOIN servers s ON s.id = urs.server_id
WHERE urs.status = 'offline'`,
)
}
function loadLiveProbeIssues(): LiveProbeRow[] {
return sqliteDatabase
.prepare(
`SELECT p.name AS probeName, p.target AS target, ups.sampled_at AS sampledAt,
ups.status AS status, ups.rtt_ms AS rttMs, ups.loss_pct AS lossPct
FROM uptime_probe_samples ups
INNER JOIN (
SELECT probe_id, MAX(id) AS mid FROM uptime_probe_samples GROUP BY probe_id
) latest ON latest.mid = ups.id
INNER JOIN uptime_probes p ON p.id = ups.probe_id
WHERE ups.status IN ('down', 'warn')`,
)
.all() as LiveProbeRow[]
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
return dbAll<LiveProbeRow>(
`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
FROM uptime_probe_samples ups
INNER JOIN (
SELECT DISTINCT ON (probe_id) probe_id, sampled_at, status, rtt_ms, loss_pct
FROM uptime_probe_samples
ORDER BY probe_id, sampled_at DESC
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
INNER JOIN uptime_probes p ON p.id = ups.probe_id
WHERE ups.status IN ('down', 'warn')`,
)
}
function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
const out: ApiAlertHistoryEntry[] = []
for (const r of loadLiveResourceIssues()) {
for (const r of await loadLiveResourceIssues()) {
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
@@ -505,7 +498,7 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
source: "live",
})
}
for (const p of loadLiveProbeIssues()) {
for (const p of await loadLiveProbeIssues()) {
const sev = p.status === "down" ? "critical" : "warning"
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
@@ -523,13 +516,12 @@ function toLiveHistoryEntries(): ApiAlertHistoryEntry[] {
return out
}
export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
const rows = db
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
const rows = await db
.select()
.from(alertHistory)
.orderBy(desc(alertHistory.firedAt))
.limit(limit)
.all()
return rows.map((h) => ({
id: h.id,
ruleName: h.ruleName,
@@ -541,9 +533,9 @@ export function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] {
}))
}
export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHistoryEntry[] {
const persisted = listPersistedHistory(persistLimit)
const live = toLiveHistoryEntries().slice(0, liveCap)
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
const persisted = await listPersistedHistory(persistLimit)
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
const seen = new Set<string>()
const dedup: ApiAlertHistoryEntry[] = []
@@ -556,8 +548,8 @@ export function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHist
return dedup
}
export function getAlertsMeta() {
const srvRows = db
export async function getAlertsMeta() {
const srvRows = await db
.select({
name: servers.name,
host: servers.host,
@@ -566,7 +558,6 @@ export function getAlertsMeta() {
type: servers.type,
})
.from(servers)
.all()
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
const serversDetail = srvRows.map((r) => ({
name: r.name?.trim() || "",
@@ -575,7 +566,7 @@ export function getAlertsMeta() {
country: r.country?.trim() || "",
type: (r.type ?? "home-router") as string,
}))
const probes = db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes).all()
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
const probeTargets = probes.map((p) => `${p.name}${p.target}`)
const defaults = ["8.8.8.8", "1.1.1.1"]
const rttLossTargets: string[] = []
@@ -18,7 +18,7 @@ export function getBackupSchedulerCollectorState(): { running: boolean } {
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getBackupScheduleSettings()
const settings = await getBackupScheduleSettings()
const snapshot: BackupsRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "backups",
@@ -49,7 +49,7 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
if (settings.format !== "rsc") {
snapshot.skipped = true
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: 0,
lastError: snapshot.errors[0],
@@ -59,9 +59,9 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
collecting = true
const started = Date.now()
const serverIds = resolveBackupServerIds(settings)
const serverIds = await resolveBackupServerIds(settings)
appendEvent({
await appendEvent({
level: "info",
eventType: "backups.job.started",
sourceModule: "backups",
@@ -88,13 +88,13 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
}
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
})
appendEvent({
await appendEvent({
level: snapshot.failures > 0 ? "warning" : "info",
eventType: "backups.job.done",
sourceModule: "backups",
@@ -115,12 +115,12 @@ export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot>
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
snapshot.errors?.push(message)
touchBackupScheduleRunMeta({
await touchBackupScheduleRunMeta({
lastRunAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
})
appendEvent({
await appendEvent({
level: "critical",
eventType: "backups.job.failed",
sourceModule: "backups",
+38 -43
View File
@@ -4,6 +4,7 @@ import path from "node:path"
import { desc, eq } from "drizzle-orm"
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { listServersRead } from "../modules/servers/service/servers-service.js"
@@ -14,7 +15,7 @@ const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
export type BackupMeta = {
id: string
serverId: string
serverId: number | null
serverName: string
filename: string
sizeBytes: number
@@ -40,17 +41,18 @@ export async function ensureBackupStorage(): Promise<void> {
await mkdir(BACKUPS_DIR, { recursive: true })
}
export function listBackups(): BackupMeta[] {
return db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt)).all().map(rowToMeta)
export async function listBackups(): Promise<BackupMeta[]> {
const rows = await db.select().from(backupEntries).orderBy(desc(backupEntries.createdAt))
return rows.map(rowToMeta)
}
export function getBackupById(id: string): BackupMeta | null {
const row = db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1).all()[0]
export async function getBackupById(id: string): Promise<BackupMeta | null> {
const row = (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
return row ? rowToMeta(row) : null
}
export function insertBackup(meta: BackupMeta): void {
db.insert(backupEntries).values({
export async function insertBackup(meta: BackupMeta): Promise<void> {
await db.insert(backupEntries).values({
id: meta.id,
serverId: meta.serverId,
serverName: meta.serverName,
@@ -59,13 +61,13 @@ export function insertBackup(meta: BackupMeta): void {
kind: meta.kind,
notes: meta.notes ?? null,
createdAt: meta.createdAt,
}).run()
})
}
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
const hit = getBackupById(id)
const hit = await getBackupById(id)
if (!hit) return null
db.delete(backupEntries).where(eq(backupEntries.id, id)).run()
await db.delete(backupEntries).where(eq(backupEntries.id, id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
return hit
}
@@ -75,18 +77,12 @@ function fmtTs(d = new Date()): string {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
}
function parseServerIds(raw: string): string[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.map(String).filter(Boolean)
} catch {
return []
}
function parseServerIds(raw: unknown): string[] {
return parseJsonArray(raw).map(String).filter(Boolean)
}
function getBackupScheduleSettingsRow() {
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
async function getBackupScheduleSettingsRow() {
return (await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]
?? {
id: SETTINGS_ID,
enabled: true,
@@ -97,7 +93,7 @@ function getBackupScheduleSettingsRow() {
monthDay: 1,
keepCount: 7,
format: "rsc" as const,
serverIdsJson: "[]",
serverIdsJson: [],
lastRunAt: null,
lastDurationMs: null,
lastError: null,
@@ -105,8 +101,8 @@ function getBackupScheduleSettingsRow() {
}
}
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
const row = getBackupScheduleSettingsRow()
export async function getBackupScheduleSettings(): Promise<BackupScheduleSettingsDto> {
const row = await getBackupScheduleSettingsRow()
return {
enabled: row.enabled,
frequency: row.frequency,
@@ -124,7 +120,7 @@ export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
}
}
export function updateBackupScheduleSettings(patch: Partial<{
export async function updateBackupScheduleSettings(patch: Partial<{
enabled: boolean
frequency: "daily" | "weekly" | "monthly"
hour: number
@@ -135,7 +131,7 @@ export function updateBackupScheduleSettings(patch: Partial<{
format: "rsc" | "backup"
serverIds: string[]
}>) {
const prev = getBackupScheduleSettingsRow()
const prev = await getBackupScheduleSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
@@ -146,33 +142,33 @@ export function updateBackupScheduleSettings(patch: Partial<{
monthDay: patch.monthDay ?? prev.monthDay,
keepCount: patch.keepCount ?? prev.keepCount,
format: patch.format ?? prev.format,
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
serverIdsJson: patch.serverIds ?? prev.serverIdsJson,
updatedAt: now,
}
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
if ((await db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID))
} else {
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
await db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next })
}
return getBackupScheduleSettings()
return await getBackupScheduleSettings()
}
export function touchBackupScheduleRunMeta(patch: {
export async function touchBackupScheduleRunMeta(patch: {
lastRunAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getBackupScheduleSettingsRow()
db.update(backupScheduleSettings).set({
const prev = await getBackupScheduleSettingsRow()
await db.update(backupScheduleSettings).set({
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
}
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
const enabled = new Set(listServersRead().map((s) => String(s.id)))
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
return [...new Set(requested)].filter((id) => enabled.has(id))
}
@@ -227,7 +223,7 @@ export async function runBackupForServer(
if (!Number.isFinite(serverIdNum)) {
throw new Error("Невалидный id сервера")
}
const row = getServerRowById(serverIdNum)
const row = await getServerRowById(serverIdNum)
if (!row) {
throw new Error("Сервер не найден")
}
@@ -242,7 +238,7 @@ export async function runBackupForServer(
const st = await stat(filePath)
const meta: BackupMeta = {
id: randomUUID(),
serverId: String(row.id),
serverId: row.id,
serverName: row.name,
filename,
sizeBytes: st.size,
@@ -250,19 +246,18 @@ export async function runBackupForServer(
kind,
notes,
}
insertBackup(meta)
await insertBackup(meta)
return meta
}
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
const rows = db.select().from(backupEntries)
.where(eq(backupEntries.serverId, serverId))
const rows = await db.select().from(backupEntries)
.where(eq(backupEntries.serverId, Number(serverId)))
.orderBy(desc(backupEntries.createdAt))
.all()
if (rows.length <= keepCount) return 0
const toDelete = rows.slice(keepCount)
for (const hit of toDelete) {
db.delete(backupEntries).where(eq(backupEntries.id, hit.id)).run()
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
}
return toDelete.length
+1 -1
View File
@@ -14,7 +14,7 @@ export function bgpPeerAlertKey(s: BgpSessionRead): string {
/** Агрегат BGP-сессий с включённых серверов — как `/api/bgp/sessions`. */
export async function fetchBgpSessionsForAlerts(): Promise<BgpSessionRead[]> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
allServers.map(async (server) => {
try {
@@ -1,3 +1,4 @@
import { parseJsonArray } from "../db/json.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
import {
@@ -11,15 +12,15 @@ const runningJobs = new Set<string>()
export async function runCertificateIssueJob(jobId: string): Promise<void> {
if (runningJobs.has(jobId)) return
runningJobs.add(jobId)
const row = getIssueJobRecord(jobId)
const row = await getIssueJobRecord(jobId)
if (!row) {
runningJobs.delete(jobId)
return
}
const server = getServerRowByIdString(row.serverId)
const server = await getServerRowByIdString(row.serverId)
if (!server) {
updateIssueJobRecord(jobId, {
await updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
@@ -29,20 +30,15 @@ export async function runCertificateIssueJob(jobId: string): Promise<void> {
return
}
let domainNames: string[] = []
try {
domainNames = JSON.parse(row.domainNames) as string[]
} catch {
domainNames = []
}
const domainNames = parseJsonArray(row.domainNames).map(String)
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
const startedAt = new Date().toISOString()
const isScheduler = row.source === "scheduler"
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
await updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
if (isScheduler) {
appendEvent({
await appendEvent({
level: "info",
eventType: "certificates.renew.started",
sourceModule: "certificates",
@@ -60,15 +56,15 @@ export async function runCertificateIssueJob(jobId: string): Promise<void> {
domainNames,
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
onStep: (step) => updateIssueJobRecord(jobId, { step }),
onStep: (step) => { void updateIssueJobRecord(jobId, { step }) },
})
updateIssueJobRecord(jobId, {
await updateIssueJobRecord(jobId, {
status: "done",
step: "done",
finishedAt: new Date().toISOString(),
error: null,
})
appendEvent({
await appendEvent({
level: "info",
eventType: isScheduler ? "certificates.renew.done" : "certificates.issue.done",
sourceModule: "certificates",
@@ -79,13 +75,13 @@ export async function runCertificateIssueJob(jobId: string): Promise<void> {
})
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
updateIssueJobRecord(jobId, {
await updateIssueJobRecord(jobId, {
status: "failed",
step: "failed",
finishedAt: new Date().toISOString(),
error: message,
})
appendEvent({
await appendEvent({
level: "warning",
eventType: isScheduler ? "certificates.renew.failed" : "certificates.issue.failed",
sourceModule: "certificates",
@@ -22,7 +22,7 @@ export function getCertificateRenewCollectorState(): { running: boolean } {
export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewRunSnapshot> {
const sampledAt = new Date().toISOString()
const settings = getCertificateRenewSettings()
const settings = await getCertificateRenewSettings()
if (collecting) {
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
@@ -55,7 +55,7 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
return snapshot
}
const targets = listManagedCertificateTargets()
const targets = await listManagedCertificateTargets()
for (const target of targets) {
snapshot.checked += 1
const item = {
@@ -66,12 +66,12 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
message: "",
}
if (hasActiveIssueJob(target.serverId, target.certName)) {
if (await hasActiveIssueJob(target.serverId, target.certName)) {
item.action = "skipped"
item.message = "Уже выполняется выпуск"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
appendEvent({
await appendEvent({
level: "info",
eventType: "certificates.renew.skipped",
sourceModule: "certificates",
@@ -83,7 +83,7 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
continue
}
const server = getServerRowByIdString(target.serverId)
const server = await getServerRowByIdString(target.serverId)
if (!server) {
item.action = "error"
item.message = "Сервер не найден"
@@ -114,7 +114,7 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
}
const jobId = randomUUID()
createIssueJobRecord({
await createIssueJobRecord({
id: jobId,
serverId: target.serverId,
certName: target.certName,
@@ -137,7 +137,7 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
}
}
touchCertificateRenewRunMeta({
await touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: snapshot.errors.length > 0 ? snapshot.errors.join("; ") : null,
@@ -146,7 +146,7 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
snapshot.fatalError = message
touchCertificateRenewRunMeta({
await touchCertificateRenewRunMeta({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: message,
+51 -65
View File
@@ -1,6 +1,7 @@
import { desc, eq, inArray } from "drizzle-orm"
import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import {
acmeSettings,
certificateIssueJobs,
@@ -25,7 +26,7 @@ export async function listCertificatesFromServers(): Promise<{
certificates: CertificateDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const certificates: CertificateDto[] = []
const failures: Array<{ serverId: string; serverName?: string; error: string }> = []
@@ -53,8 +54,8 @@ export async function listCertificatesFromServers(): Promise<{
return { certificates, failures }
}
export function getAcmeSettingsPublic() {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
export async function getAcmeSettingsPublic() {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
if (!row) {
return {
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
@@ -71,30 +72,29 @@ export function getAcmeSettingsPublic() {
}
}
export function getAcmeCloudflareToken(): string {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
export async function getAcmeCloudflareToken(): Promise<string> {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
return row?.cloudflareApiToken?.trim() ?? ""
}
export function getAcmeAccountPrivateKey(): string {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
export async function getAcmeAccountPrivateKey(): Promise<string> {
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
return row?.accountPrivateKey?.trim() ?? ""
}
export function saveAcmeAccountPrivateKey(pem: string) {
export async function saveAcmeAccountPrivateKey(pem: string) {
const now = new Date().toISOString()
db.update(acmeSettings)
await db.update(acmeSettings)
.set({ accountPrivateKey: pem, updatedAt: now })
.where(eq(acmeSettings.id, SETTINGS_ID))
.run()
}
export function updateAcmeSettings(input: {
export async function updateAcmeSettings(input: {
directoryUrl?: string
defaultZoneId?: string | null
cloudflareApiToken?: string | null
}) {
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
const row = (await db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1))[0]
const now = new Date().toISOString()
const next = {
directoryUrl: input.directoryUrl?.trim() || row?.directoryUrl || "https://acme-v02.api.letsencrypt.org/directory",
@@ -109,14 +109,14 @@ export function updateAcmeSettings(input: {
updatedAt: now,
}
if (row) {
db.update(acmeSettings).set(next).where(eq(acmeSettings.id, SETTINGS_ID)).run()
await db.update(acmeSettings).set(next).where(eq(acmeSettings.id, SETTINGS_ID))
} else {
db.insert(acmeSettings).values({ id: SETTINGS_ID, accountPrivateKey: "", ...next }).run()
await db.insert(acmeSettings).values({ id: SETTINGS_ID, accountPrivateKey: "", ...next })
}
return getAcmeSettingsPublic()
return await getAcmeSettingsPublic()
}
export function createIssueJobRecord(input: {
export async function createIssueJobRecord(input: {
id: string
serverId: string
certName: string
@@ -126,21 +126,21 @@ export function createIssueJobRecord(input: {
source?: "manual" | "scheduler"
}) {
const now = new Date().toISOString()
db.insert(certificateIssueJobs).values({
await db.insert(certificateIssueJobs).values({
id: input.id,
status: "queued",
step: "queued",
source: input.source ?? "manual",
serverId: input.serverId,
serverId: Number(input.serverId),
certName: input.certName,
domainNames: JSON.stringify(input.domainNames),
domainNames: input.domainNames,
keyType: input.keyType,
trustStore: input.trustStore,
requestedAt: now,
}).run()
})
}
export function updateIssueJobRecord(
export async function updateIssueJobRecord(
id: string,
patch: Partial<{
status: "queued" | "running" | "done" | "failed"
@@ -150,21 +150,15 @@ export function updateIssueJobRecord(
error: string | null
}>,
) {
db.update(certificateIssueJobs).set(patch).where(eq(certificateIssueJobs.id, id)).run()
await db.update(certificateIssueJobs).set(patch).where(eq(certificateIssueJobs.id, id))
}
export function getIssueJobRecord(id: string) {
return db.select().from(certificateIssueJobs).where(eq(certificateIssueJobs.id, id)).limit(1).all()[0] ?? null
export async function getIssueJobRecord(id: string) {
return (await db.select().from(certificateIssueJobs).where(eq(certificateIssueJobs.id, id)).limit(1))[0] ?? null
}
export function toIssueJobDto(row: NonNullable<ReturnType<typeof getIssueJobRecord>>) {
let domainNames: string[] = []
try {
const parsed = JSON.parse(row.domainNames) as unknown
if (Array.isArray(parsed)) domainNames = parsed.map(String)
} catch {
domainNames = []
}
export function toIssueJobDto(row: NonNullable<Awaited<ReturnType<typeof getIssueJobRecord>>>) {
const domainNames = parseJsonArray(row.domainNames).map(String)
return {
id: row.id,
status: row.status,
@@ -179,14 +173,14 @@ export function toIssueJobDto(row: NonNullable<ReturnType<typeof getIssueJobReco
}
}
export function getServerRowByIdString(serverId: string) {
const id = Number.parseInt(serverId, 10)
export async function getServerRowByIdString(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(serverId, 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
function getCertificateRenewSettingsRow() {
return db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]
async function getCertificateRenewSettingsRow() {
return (await db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1))[0]
?? {
id: SETTINGS_ID,
enabled: true,
@@ -199,8 +193,8 @@ function getCertificateRenewSettingsRow() {
}
}
export function getCertificateRenewSettings(): CertificateRenewSettingsDto {
const row = getCertificateRenewSettingsRow()
export async function getCertificateRenewSettings(): Promise<CertificateRenewSettingsDto> {
const row = await getCertificateRenewSettingsRow()
return {
enabled: row.enabled,
intervalSec: row.intervalSec,
@@ -212,12 +206,12 @@ export function getCertificateRenewSettings(): CertificateRenewSettingsDto {
}
}
export function updateCertificateRenewSettings(patch: {
export async function updateCertificateRenewSettings(patch: {
enabled?: boolean
intervalSec?: number
renewBeforeDays?: number
}) {
const prev = getCertificateRenewSettingsRow()
const prev = await getCertificateRenewSettingsRow()
const now = new Date().toISOString()
const next = {
enabled: patch.enabled ?? prev.enabled,
@@ -225,47 +219,40 @@ export function updateCertificateRenewSettings(patch: {
renewBeforeDays: patch.renewBeforeDays ?? prev.renewBeforeDays,
updatedAt: now,
}
if (db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
if ((await db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1))[0]) {
await db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID))
} else {
db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next }).run()
await db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next })
}
return getCertificateRenewSettings()
return await getCertificateRenewSettings()
}
export function touchCertificateRenewRunMeta(patch: {
export async function touchCertificateRenewRunMeta(patch: {
lastCollectedAt?: string
lastDurationMs?: number
lastError?: string | null
}) {
const prev = getCertificateRenewSettingsRow()
db.update(certificateRenewSettings).set({
const prev = await getCertificateRenewSettingsRow()
await db.update(certificateRenewSettings).set({
lastCollectedAt: patch.lastCollectedAt ?? prev.lastCollectedAt,
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
updatedAt: new Date().toISOString(),
}).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
}).where(eq(certificateRenewSettings.id, SETTINGS_ID))
}
export function listManagedCertificateTargets(): ManagedCertificateTarget[] {
const rows = db.select().from(certificateIssueJobs)
export async function listManagedCertificateTargets(): Promise<ManagedCertificateTarget[]> {
const rows = await db.select().from(certificateIssueJobs)
.where(eq(certificateIssueJobs.status, "done"))
.orderBy(desc(certificateIssueJobs.finishedAt), desc(certificateIssueJobs.requestedAt))
.all()
const byKey = new Map<string, ManagedCertificateTarget>()
for (const row of rows) {
const key = `${row.serverId}::${row.certName}`
if (byKey.has(key)) continue
let domainNames: string[] = []
try {
const parsed = JSON.parse(row.domainNames) as unknown
if (Array.isArray(parsed)) domainNames = parsed.map(String)
} catch {
domainNames = []
}
const domainNames = parseJsonArray(row.domainNames).map(String)
byKey.set(key, {
serverId: row.serverId,
serverId: row.serverId == null ? "" : String(row.serverId),
certName: row.certName,
domainNames,
keyType: row.keyType,
@@ -276,11 +263,10 @@ export function listManagedCertificateTargets(): ManagedCertificateTarget[] {
return [...byKey.values()]
}
export function hasActiveIssueJob(serverId: string, certName: string): boolean {
const row = db.select().from(certificateIssueJobs)
export async function hasActiveIssueJob(serverId: string, certName: string): Promise<boolean> {
const rows = await db.select().from(certificateIssueJobs)
.where(inArray(certificateIssueJobs.status, ["queued", "running"]))
.orderBy(desc(certificateIssueJobs.requestedAt))
.all()
.find((r) => r.serverId === serverId && r.certName === certName)
return Boolean(row)
const sid = Number(serverId)
return rows.some((r) => r.serverId === sid && r.certName === certName)
}
+1 -1
View File
@@ -179,7 +179,7 @@ export async function listFirewallAll(): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
@@ -41,8 +41,8 @@ export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnap
try {
const [greSettled, bgpSettled] = await Promise.allSettled([
fetchGreTunnelLiveRows(),
fetchBgpSessionsForAlerts(),
await fetchGreTunnelLiveRows(),
await fetchBgpSessionsForAlerts(),
])
if (greSettled.status === "rejected") {
+1 -1
View File
@@ -46,7 +46,7 @@ async function fetchGreForServer(server: ServerRow): Promise<GreTunnelLiveRow[]>
/** GRE с включённых серверов — тот же смысл, что `/api/filters/gre-tunnels` (без правил фильтров). */
export async function fetchGreTunnelLiveRows(): Promise<GreTunnelLiveRow[]> {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
const results = await Promise.all(
enabledServers.map(async (server) => {
try {
+39 -38
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, lt } from "drizzle-orm"
import { and, asc, desc, eq, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import {
filterRules,
@@ -30,29 +30,29 @@ function norm(v: string | null | undefined): string {
return String(v ?? "").trim().toLowerCase()
}
function getSettingsRow() {
const row = db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0]
async function getSettingsRow() {
const row = (await db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1))[0]
if (row) return row
const now = new Date().toISOString()
db.insert(internetPathSettings).values({
await db.insert(internetPathSettings).values({
id: 1,
enabled: true,
intervalSec: 300,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1).all()[0]
})
return (await db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1))[0]
}
function cleanupSnapshots(retentionDays: number) {
async function cleanupSnapshots(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff)).run()
await db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff))
}
function buildRulesets() {
const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all()
const rules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
async function buildRulesets() {
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
const rules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
return enabled.map((s) => ({
serverId: String(s.id),
rules: rules
@@ -70,7 +70,7 @@ function buildRulesets() {
}
async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row) return { gateway: null, routingMark: null }
const client = MikrotikClient.fromServer(row)
const routes = await client.get<Array<Record<string, string>>>("/ip/route").catch(() => [])
@@ -91,8 +91,8 @@ async function readRouteLookup(serverId: number): Promise<{ gateway: string | nu
}
async function readWanRuntime(serverId: number) {
const server = listServersRead().find((s) => Number(s.id) === serverId)
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const server = (await listServersRead()).find((s) => Number(s.id) === serverId)
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!server || !row) return null
const client = MikrotikClient.fromServer(row)
const [dhcpRaw, ipAddrs, routes] = await Promise.all([
@@ -144,8 +144,8 @@ async function readWanRuntime(serverId: number) {
}
}
function mapSpeedProbes() {
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
async function mapSpeedProbes() {
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
return rows.map((r) => ({
id: r.id,
srcServerId: String(r.srcServerId),
@@ -175,7 +175,7 @@ function parseInnerIps(comment: string): { localInnerIp: string; remoteInnerIp:
}
async function collectGreTunnels() {
const enabled = db.select().from(servers).where(eq(servers.enabled, true)).all()
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
const all = await Promise.all(enabled.map(async (srv) => {
try {
const client = MikrotikClient.fromServer(srv)
@@ -214,23 +214,24 @@ async function collectGreTunnels() {
return all.flat()
}
export function getInternetPathSettings() {
return getSettingsRow()
export async function getInternetPathSettings() {
return await getSettingsRow()
}
export function updateInternetPathSettings(patch: { enabled?: boolean; intervalSec?: number; retentionDays?: number }) {
const prev = getSettingsRow()
db.update(internetPathSettings).set({
export async function updateInternetPathSettings(patch: { enabled?: boolean; intervalSec?: number; retentionDays?: number }) {
const prev = await getSettingsRow()
await db.update(internetPathSettings).set({
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}).where(eq(internetPathSettings.id, 1)).run()
return getSettingsRow()
}).where(eq(internetPathSettings.id, 1))
return await getSettingsRow()
}
export function getLatestInternetPathSnapshot() {
return db.select().from(internetPathSnapshots).orderBy(asc(internetPathSnapshots.id)).all().at(-1) ?? null
export async function getLatestInternetPathSnapshot() {
const rows = await db.select().from(internetPathSnapshots).orderBy(desc(internetPathSnapshots.id)).limit(1)
return rows[0] ?? null
}
export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRunSnapshot> {
@@ -246,12 +247,12 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
}
collecting = true
const started = Date.now()
const settings = getSettingsRow()
const settings = await getSettingsRow()
try {
const serversRead = listServersRead()
const serversRead = await listServersRead()
const homes = serversRead.filter((s) => s.type === "home-router")
const [greTunnels, rulesets] = await Promise.all([collectGreTunnels(), Promise.resolve(buildRulesets())])
const speedProbes = mapSpeedProbes()
const [greTunnels, rulesets] = await Promise.all([await collectGreTunnels(), Promise.resolve(await buildRulesets())])
const speedProbes = await mapSpeedProbes()
const routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null }> = {}
const wanRuntimeByHomeId: Record<string, unknown> = {}
for (const h of homes) {
@@ -268,17 +269,17 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
routeLookupByServerId,
wanRuntimeByHomeId,
}
db.insert(internetPathSnapshots).values({
await db.insert(internetPathSnapshots).values({
sampledAt,
payloadJson: JSON.stringify(payload),
}).run()
cleanupSnapshots(Math.max(1, settings.retentionDays))
db.update(internetPathSettings).set({
payloadJson: payload,
})
await cleanupSnapshots(Math.max(1, settings.retentionDays))
await db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1)).run()
}).where(eq(internetPathSettings.id, 1))
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
@@ -288,12 +289,12 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.update(internetPathSettings).set({
await db.update(internetPathSettings).set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - started,
lastError: msg,
updatedAt: sampledAt,
}).where(eq(internetPathSettings.id, 1)).run()
}).where(eq(internetPathSettings.id, 1))
return {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
+13 -11
View File
@@ -1,5 +1,6 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { eq, lt } from "drizzle-orm"
import { db, pool } from "../db/index.js"
import { dropExpiredPartitions, ensurePartitionsAround } from "../db/partitions.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
import type { SnapshotRead } from "../types/server.js"
@@ -14,12 +15,11 @@ import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
* and sync the server's display name from system/identity.
*/
export async function pollServer(serverId: number): Promise<SnapshotRead> {
const server = db
const server = (await db
.select()
.from(servers)
.where(eq(servers.id, serverId))
.limit(1)
.all()[0]
.limit(1))[0]
if (!server) {
throw new Error(`Server with id=${serverId} not found`)
@@ -61,15 +61,14 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
cpuLoad,
freeMemory: freeMem,
totalMemory: totalMem,
rawInterfaces: JSON.stringify(ifaces),
rawIpAddresses: JSON.stringify(addresses),
rawInterfaces: ifaces,
rawIpAddresses: addresses,
} satisfies Partial<SnapshotInsert>)
if ((identity.name || "") !== (server.name || "")) {
db.update(servers)
await db.update(servers)
.set({ name: identity.name, updatedAt: now })
.where(eq(servers.id, serverId))
.run()
invalidateFlowCatalogCache()
}
@@ -78,11 +77,14 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
console.warn(`[poller] server id=${serverId} unreachable:`, (err as Error).message)
}
const [inserted] = db
const [inserted] = await db
.insert(serverSnapshots)
.values(partialSnap as SnapshotInsert)
.returning()
.all()
const cutoffIso = new Date(Date.now() - 14 * 24 * 3600_000).toISOString()
await db.delete(serverSnapshots).where(lt(serverSnapshots.polledAt, cutoffIso))
void dropExpiredPartitions(pool).then(() => ensurePartitionsAround(pool))
return toSnapshotRead(inserted)
}
+37 -40
View File
@@ -6,7 +6,7 @@
* 2. `traffic`, `uptime_resources`, `servers_rest_ping` — средний (REST `/system/identity` по каталогу).
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
*
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
* **Оповещения (`alert_engine`):** читают PostgreSQL после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
* `gre_bgp` → snapshot в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`). После успешного завершения джоб
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
@@ -89,7 +89,7 @@ function newRunId(): string {
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function appendSchedulerRun(row: {
async function appendSchedulerRun(row: {
jobKey: string
startedAt: string
finishedAt: string
@@ -99,8 +99,8 @@ function appendSchedulerRun(row: {
result?: SchedulerRunSnapshot | null
}) {
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
db.transaction((tx) => {
tx.insert(schedulerRuns).values({
await db.transaction(async (tx) => {
await tx.insert(schedulerRuns).values({
id: newRunId(),
jobKey: row.jobKey,
startedAt: row.startedAt,
@@ -108,10 +108,10 @@ function appendSchedulerRun(row: {
status: row.status,
error: row.error,
durationMs: row.durationMs,
resultJson: row.result ? JSON.stringify(row.result) : null,
}).run()
tx.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
tx.delete(events).where(lt(events.createdAt, cutoff)).run()
resultJson: row.result ?? null,
})
await tx.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff))
await tx.delete(events).where(lt(events.createdAt, cutoff))
})
}
@@ -167,7 +167,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
throw new Error(`Unknown job: ${jobKey}`)
}
const finishedIso = new Date().toISOString()
appendSchedulerRun({
await appendSchedulerRun({
jobKey,
startedAt: startedIso,
finishedAt: finishedIso,
@@ -177,7 +177,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
result: snapshot ?? null,
})
if (shouldAppendSchedulerOkEvent(jobKey)) {
appendEvent({
await appendEvent({
level: "info",
eventType: "scheduler.job.ok",
sourceModule: "scheduler",
@@ -205,7 +205,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
const finishedIso = new Date().toISOString()
appendSchedulerRun({
await appendSchedulerRun({
jobKey,
startedAt: startedIso,
finishedAt: finishedIso,
@@ -214,7 +214,7 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
durationMs: Date.now() - startedAt,
result: snapshot ?? null,
})
appendEvent({
await appendEvent({
level: "critical",
eventType: "scheduler.job.failed",
sourceModule: "scheduler",
@@ -267,10 +267,10 @@ function clearAllTimers() {
}
/** Пересоздать интервалы после смены настроек. */
export function refreshScheduler(): void {
export async function refreshScheduler(): Promise<void> {
clearAllTimers()
const traffic = getTrafficSettings()
const traffic = await getTrafficSettings()
if (traffic.enabled) {
const ms = Math.max(5_000, traffic.intervalSec * 1000)
void executeSchedulerJob("traffic").catch(() => {})
@@ -282,8 +282,8 @@ export function refreshScheduler(): void {
)
}
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
const apiPing = await getServersApiPingSettings()
const internetPath = await getInternetPathSettings()
if (apiPing.enabled) {
const apiMs = Math.max(10_000, apiPing.intervalSec * 1000)
void executeSchedulerJob("servers_rest_ping").catch(() => {})
@@ -295,7 +295,7 @@ export function refreshScheduler(): void {
)
}
const uptime = getUptimeSettings()
const uptime = await getUptimeSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
const spdOn = uptime.speedEnabled ?? uptime.enabled
@@ -353,7 +353,7 @@ export function refreshScheduler(): void {
}, greBgpMs),
)
const certRenew = getCertificateRenewSettings()
const certRenew = await getCertificateRenewSettings()
if (certRenew.enabled) {
const certRenewMs = Math.max(300_000, certRenew.intervalSec * 1000)
void executeSchedulerJob("certificates_renew").catch(() => {})
@@ -365,7 +365,7 @@ export function refreshScheduler(): void {
)
}
const backupSchedule = getBackupScheduleSettings()
const backupSchedule = await getBackupScheduleSettings()
if (backupSchedule.enabled) {
const backupMs = 60_000
void executeSchedulerJob("backups").catch(() => {})
@@ -395,21 +395,20 @@ export function isJobRunning(jobKey: string): boolean {
return isSchedulerJobRunning(jobKey)
}
function lastRunForJob(jobKey: string) {
return db.select().from(schedulerRuns)
async function lastRunForJob(jobKey: string) {
return (await db.select().from(schedulerRuns)
.where(eq(schedulerRuns.jobKey, jobKey))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(1)
.all()[0]
.limit(1))[0]
}
export function getSchedulerStatus() {
const traffic = getTrafficSettings()
const uptime = getUptimeSettings()
const apiPing = getServersApiPingSettings()
const internetPath = getInternetPathSettings()
const certRenew = getCertificateRenewSettings()
const backupSchedule = getBackupScheduleSettings()
export async function getSchedulerStatus() {
const traffic = await getTrafficSettings()
const uptime = await getUptimeSettings()
const apiPing = await getServersApiPingSettings()
const internetPath = await getInternetPathSettings()
const certRenew = await getCertificateRenewSettings()
const backupSchedule = await getBackupScheduleSettings()
const resOn = uptime.resourcesEnabled ?? uptime.enabled
const pingOn = uptime.pingEnabled ?? uptime.enabled
@@ -429,8 +428,8 @@ export function getSchedulerStatus() {
}
return {
jobs: JOB_KEYS.map((jobKey) => {
const last = lastRunForJob(jobKey)
jobs: await Promise.all(JOB_KEYS.map(async (jobKey) => {
const last = await lastRunForJob(jobKey)
const m = jobMeta[jobKey]
return {
jobKey,
@@ -442,29 +441,27 @@ export function getSchedulerStatus() {
lastDurationMs: last?.durationMs ?? null,
lastError: last?.error ?? null,
}
}),
})),
}
}
export function listSchedulerRuns(opts: { jobKey?: string; limit: number; offset: number }) {
export async function listSchedulerRuns(opts: { jobKey?: string; limit: number; offset: number }) {
const limit = Math.min(200, Math.max(1, opts.limit))
const offset = Math.max(0, opts.offset)
if (opts.jobKey) {
return {
runs: db.select().from(schedulerRuns)
runs: await db.select().from(schedulerRuns)
.where(eq(schedulerRuns.jobKey, opts.jobKey))
.orderBy(desc(schedulerRuns.finishedAt))
.limit(limit)
.offset(offset)
.all(),
.offset(offset),
}
}
return {
runs: db.select().from(schedulerRuns)
runs: await db.select().from(schedulerRuns)
.orderBy(desc(schedulerRuns.finishedAt))
.limit(limit)
.offset(offset)
.all(),
.offset(offset),
}
}
@@ -1,5 +1,5 @@
import { eq, or } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import { db, dbAll, dbQuery } from "../db/index.js"
import { servers, serversApiPingSettings, serversRestPingSamples } from "../db/schema.js"
import type { ServersRestPingRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
@@ -7,75 +7,67 @@ import { MikrotikClient } from "./mikrotik.js"
let collecting = false
function getSettingsRow() {
const row = db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0]
async function getSettingsRow() {
const row = (await db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1))[0]
if (row) return row
const now = new Date().toISOString()
db.insert(serversApiPingSettings).values({
await db.insert(serversApiPingSettings).values({
id: 1,
enabled: false,
intervalSec: 120,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0]
})
return (await db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1))[0]
}
/** Как uptime: включённые в каталоге или Home Router (часто «выкл.» при доступе только из LAN). */
function serversForRestProbe() {
return db
async function serversForRestProbe() {
return await db
.select()
.from(servers)
.where(or(eq(servers.enabled, true), eq(servers.type, "home-router")))
.all()
}
export function getServersApiPingSettings() {
return getSettingsRow()
export async function getServersApiPingSettings() {
return await getSettingsRow()
}
export function updateServersApiPingSettings(patch: { enabled?: boolean; intervalSec?: number }) {
const prev = getSettingsRow()
export async function updateServersApiPingSettings(patch: { enabled?: boolean; intervalSec?: number }) {
const prev = await getSettingsRow()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
updatedAt: new Date().toISOString(),
}
db.update(serversApiPingSettings).set(next).where(eq(serversApiPingSettings.id, 1)).run()
return getSettingsRow()
await db.update(serversApiPingSettings).set(next).where(eq(serversApiPingSettings.id, 1))
return await getSettingsRow()
}
export function getServersRestPingCollectorState(): { running: boolean } {
return { running: collecting }
}
function loadLastRestSampleByServer(): Map<number, { ok: boolean }> {
const rows = sqliteDatabase
.prepare(
`SELECT s.server_id AS serverId, s.ok
FROM servers_rest_ping_samples s
INNER JOIN (
SELECT server_id, MAX(id) AS mid FROM servers_rest_ping_samples GROUP BY server_id
) t ON t.mid = s.id`,
)
.all() as { serverId: number; ok: number }[]
async function loadLastRestSampleByServer(): Promise<Map<number, { ok: boolean }>> {
const rows = await dbAll<{ serverId: number; ok: boolean | number }>(
`SELECT DISTINCT ON (server_id) server_id AS "serverId", ok
FROM servers_rest_ping_samples
ORDER BY server_id, sampled_at DESC`,
)
const m = new Map<number, { ok: boolean }>()
for (const r of rows) m.set(r.serverId, { ok: Boolean(r.ok) })
return m
}
/** Последний сэмпл ресурсов — для согласования «восстановился» с REST без второго тика uptime. */
function loadLatestResourceDownServerIds(): Set<number> {
const rows = sqliteDatabase
.prepare(
`WITH r AS (
SELECT urs.server_id AS serverId, urs.status,
ROW_NUMBER() OVER (PARTITION BY urs.server_id ORDER BY urs.id DESC) AS rn
FROM uptime_resource_samples urs
)
SELECT serverId FROM r WHERE rn = 1 AND status IN ('offline', 'degraded')`,
)
.all() as { serverId: number }[]
async function loadLatestResourceDownServerIds(): Promise<Set<number>> {
const rows = await dbAll<{ serverId: number }>(
`SELECT server_id AS "serverId" FROM (
SELECT DISTINCT ON (server_id) server_id, status
FROM uptime_resource_samples
ORDER BY server_id, sampled_at DESC
) latest WHERE status IN ('offline', 'degraded')`,
)
return new Set(rows.map((x) => x.serverId))
}
@@ -104,7 +96,7 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
}
try {
const targets = serversForRestProbe()
const targets = await serversForRestProbe()
const rows = await Promise.all(
targets.map(async (srv) => {
const name = srv.name || srv.host
@@ -134,23 +126,23 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
)
snapshot.servers = rows.sort((a, b) => a.serverId - b.serverId)
const lastByServer = loadLastRestSampleByServer()
const resourceDown = loadLatestResourceDownServerIds()
const lastByServer = await loadLastRestSampleByServer()
const resourceDown = await loadLatestResourceDownServerIds()
db.transaction((tx) => {
await db.transaction(async (tx) => {
for (const row of snapshot.servers) {
const sid = row.serverId
const last = lastByServer.get(sid)
const errText = row.ok ? null : (row.error ?? null)
const insertOne = (ok: boolean, latencyMs: number | null, error: string | null) => {
tx.insert(serversRestPingSamples).values({
const insertOne = async (ok: boolean, latencyMs: number | null, error: string | null) => {
await tx.insert(serversRestPingSamples).values({
serverId: sid,
sampledAt,
ok,
latencyMs,
error,
}).run()
})
}
const lat = row.latencyMs ?? null
@@ -158,23 +150,21 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
if (row.ok) {
const downRes = resourceDown.has(sid)
if (last === undefined && downRes) {
insertOne(false, null, null)
await insertOne(false, null, null)
}
insertOne(true, lat, null)
await insertOne(true, lat, null)
if (last?.ok === false || (last === undefined && downRes)) {
insertOne(true, lat, null)
await insertOne(true, lat, null)
}
} else {
insertOne(false, null, errText)
await insertOne(false, null, errText)
}
}
})
sqliteDatabase
.prepare(`DELETE FROM servers_rest_ping_samples WHERE sampled_at < datetime('now', '-30 days')`)
.run()
await dbQuery(`DELETE FROM servers_rest_ping_samples WHERE sampled_at < now() - interval '30 days'`)
db.update(serversApiPingSettings)
await db.update(serversApiPingSettings)
.set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - startedAt,
@@ -182,11 +172,10 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
updatedAt: sampledAt,
})
.where(eq(serversApiPingSettings.id, 1))
.run()
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
snapshot.fatalError = msg
db.update(serversApiPingSettings)
await db.update(serversApiPingSettings)
.set({
lastCollectedAt: sampledAt,
lastDurationMs: Date.now() - startedAt,
@@ -194,7 +183,6 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
updatedAt: sampledAt,
})
.where(eq(serversApiPingSettings.id, 1))
.run()
} finally {
collecting = false
}
@@ -1,144 +0,0 @@
import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { countSqliteWrites, sqliteDatabase } from "../db/index.js"
import { events } from "../db/schema.js"
import { db } from "../db/index.js"
import {
attachEngineSqlite,
bumpPacketMeta,
configureEngine,
flushPending,
ingestParsedFlowsForServerForTests,
resetEngineForTests,
setEngineError,
} from "./traffic-flow-engine.js"
import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js"
import { shouldAppendSchedulerOkEvent, executeSchedulerJob } from "./scheduler.js"
import { savePrevLiveMap, loadPrevLiveMap } from "./alert-engine/prev-live-store.js"
import {
invalidateFlowCatalogCache,
loadFlowTopology,
seedFlowTopologyForTests,
} from "./traffic-flow-topology.js"
resetEngineForTests()
attachEngineSqlite(sqliteDatabase)
seedFlowTopologyForTests(null)
invalidateFlowCatalogCache()
const idle1 = countSqliteWrites(() => {
flushPending()
})
assert.equal(idle1.stats.walCheckpoint, 0)
assert.ok(idle1.stats.update >= 1, "first idle flush persists listener stats")
const idle2 = countSqliteWrites(() => {
flushPending()
})
assert.equal(idle2.stats.update, 0, "unchanged listener stats skip UPDATE")
assert.equal(idle2.stats.walCheckpoint, 0)
bumpPacketMeta("203.0.113.9")
const changed = countSqliteWrites(() => {
flushPending()
})
assert.equal(changed.stats.update, 1, "changed packets persist once")
assert.equal(changed.stats.walCheckpoint, 0)
setEngineError("boom")
const errWrite = countSqliteWrites(() => {
flushPending()
})
assert.equal(errWrite.stats.update, 1)
setEngineError("")
flushPending()
resetEngineForTests()
configureEngine({ topN: 20 })
ingestParsedFlowsForServerForTests(9, [{
src: "10.1.1.1",
dst: "8.8.8.8",
proto: 6,
srcPort: 40000,
dstPort: 443,
bytes: 100,
packets: 1,
inIface: "2",
outIface: "",
}])
const withData = countSqliteWrites(() => {
flushPending()
})
assert.equal(withData.stats.walCheckpoint, 0, "flush with data must not TRUNCATE WAL")
assert.ok(withData.stats.insert >= 1, "flow upsert writes")
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
const plan = sqliteDatabase.prepare(`
EXPLAIN QUERY PLAN
SELECT id FROM flow_buckets WHERE bucket_at >= ? ORDER BY bytes DESC LIMIT 100
`).all("2000-01-01T00:00:00.000Z") as Array<{ detail?: string }>
const planText = plan.map((p) => String(p.detail ?? "")).join(" | ")
assert.ok(planText.length > 0, "EXPLAIN QUERY PLAN returned rows")
invalidateFlowCatalogCache()
seedFlowTopologyForTests(null)
const topoA = loadFlowTopology()
const topoB = loadFlowTopology()
assert.equal(topoA, topoB, "topology cache returns same object")
invalidateFlowCatalogCache()
const topoC = loadFlowTopology()
assert.notEqual(topoA, topoC, "invalidate rebuilds topology")
assert.equal(shouldAppendSchedulerOkEvent("traffic"), false)
assert.equal(shouldAppendSchedulerOkEvent("alert_engine"), false)
assert.equal(shouldAppendSchedulerOkEvent("gre_bgp"), false)
assert.equal(shouldAppendSchedulerOkEvent("backups"), true)
assert.equal(shouldAppendSchedulerOkEvent("certificates_renew"), true)
assert.equal(shouldAppendSchedulerOkEvent("internet_path"), true)
savePrevLiveMap("gre", { a: "up" })
const prevSame = countSqliteWrites(() => {
savePrevLiveMap("gre", { a: "up" })
})
assert.equal(prevSame.stats.update, 0)
assert.equal(prevSame.stats.insert, 0)
savePrevLiveMap("gre", { a: "down" })
assert.equal(loadPrevLiveMap("gre").a, "down")
savePrevLiveMap("gre", { a: "up" })
const greBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
const bgpBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
await collectGreBgpSnapshotOnce()
const greAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
const bgpAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
assert.equal(greAfter.n, greBefore.n)
assert.equal(bgpAfter.n, bgpBefore.n)
const oldId = `evt-old-${randomUUID()}`
db.insert(events).values({
id: oldId,
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(),
level: "info",
eventType: "test.retention",
sourceModule: "system",
title: "old",
message: "old",
}).run()
const eventsBefore = sqliteDatabase.prepare(
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
).get() as { n: number }
await executeSchedulerJob("alert_engine")
const oldGone = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM events WHERE id = ?`).get(oldId) as { n: number }
assert.equal(oldGone.n, 0, "events older than 30 days are purged")
const eventsAfter = sqliteDatabase.prepare(
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
).get() as { n: number }
assert.equal(eventsAfter.n, eventsBefore.n, "quiet jobs do not append scheduler.job.ok")
resetEngineForTests()
console.log("sqlite-write-opt.test.ts: ok")
console.log("EXPLAIN listStoredFlowRows:", planText)
+54 -50
View File
@@ -1,68 +1,78 @@
import { randomUUID } from "node:crypto"
import { spawn } from "node:child_process"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import Database from "better-sqlite3"
import { env } from "../config.js"
import { beginSqliteExclusiveOp, endSqliteExclusiveOp, reopenSqlite, sqliteDatabase } from "../db/index.js"
import { withAdvisoryLock } from "../db/index.js"
import { refreshScheduler, stopScheduler } from "./scheduler.js"
import {
reattachFlowSqlite,
startTrafficFlowListener,
stopTrafficFlowListener,
} from "./traffic-flow-ingest.js"
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
const PG_DUMP_MAGIC = Buffer.from("PGDMP")
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
type SqliteHandle = InstanceType<typeof Database>
const BACKUP_LOCK_KEY = 8_723_102
function fmtTimestamp(date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`
}
function resolveDatabasePath(): string {
return path.resolve(process.cwd(), env.DATABASE_PATH)
}
function assertSqliteFile(buffer: Buffer): void {
if (buffer.length < SQLITE_MAGIC.length) {
throw new Error("Файл слишком маленький для SQLite")
function assertPgDumpFile(buffer: Buffer): void {
if (buffer.length < PG_DUMP_MAGIC.length) {
throw new Error("Файл слишком маленький для дампа PostgreSQL")
}
if (!buffer.subarray(0, SQLITE_MAGIC.length).equals(SQLITE_MAGIC)) {
throw new Error("Файл не похож на резервную копию SQLite")
if (!buffer.subarray(0, PG_DUMP_MAGIC.length).equals(PG_DUMP_MAGIC)) {
throw new Error("Файл не похож на резервную копию PostgreSQL (ожидается формат custom, PGDMP)")
}
}
async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
beginSqliteExclusiveOp()
stopTrafficFlowListener()
stopScheduler()
try {
return await fn()
} finally {
startTrafficFlowListener()
refreshScheduler()
endSqliteExclusiveOp()
}
function spawnCollected(command: string, args: string[], opts?: { stdin?: Buffer }): Promise<Buffer> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: { ...process.env, DATABASE_URL: env.DATABASE_URL },
stdio: ["pipe", "pipe", "pipe"],
})
const chunks: Buffer[] = []
let err = ""
child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk))
child.stderr.on("data", (chunk: Buffer) => {
err += chunk.toString("utf8")
})
child.on("error", reject)
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(err.trim() || `${command} завершился с кодом ${code}`))
return
}
resolve(Buffer.concat(chunks))
})
if (opts?.stdin) child.stdin.end(opts.stdin)
else child.stdin.end()
})
}
async function withDatabaseOperation<T>(fn: () => Promise<T>): Promise<T> {
return await withAdvisoryLock(BACKUP_LOCK_KEY, async () => {
stopTrafficFlowListener()
stopScheduler()
try {
return await fn()
} finally {
await startTrafficFlowListener()
await refreshScheduler()
}
})
}
export async function exportSystemDatabaseBackup(): Promise<{ filename: string; buffer: Buffer }> {
return withDatabaseOperation(async () => {
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
const tempDir = path.join(os.tmpdir(), "mmapp-db-backup")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `manager-${randomUUID()}.db`)
try {
await sqliteDatabase.backup(tempPath)
const buffer = await readFile(tempPath)
return {
filename: `mikrotik-manager_${fmtTimestamp()}.db`,
buffer,
}
} finally {
await rm(tempPath, { force: true })
return await withDatabaseOperation(async () => {
const buffer = await spawnCollected("pg_dump", ["-Fc", env.DATABASE_URL])
return {
filename: `mikrotik-manager_${fmtTimestamp()}.dump`,
buffer,
}
})
}
@@ -71,27 +81,21 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void>
if (buffer.length > MAX_RESTORE_BYTES) {
throw new Error("Файл бэкапа слишком большой")
}
assertSqliteFile(buffer)
assertPgDumpFile(buffer)
await withDatabaseOperation(async () => {
const tempDir = path.join(os.tmpdir(), "mmapp-db-restore")
await mkdir(tempDir, { recursive: true })
const tempPath = path.join(tempDir, `restore-${randomUUID()}.db`)
let source: SqliteHandle | null = null
const tempPath = path.join(tempDir, `restore-${randomUUID()}.dump`)
try {
await writeFile(tempPath, buffer)
source = new Database(tempPath, { readonly: true, fileMustExist: true })
await source.backup(resolveDatabasePath())
reopenSqlite()
reattachFlowSqlite()
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
await spawnCollected("pg_restore", ["--clean", "--if-exists", "-d", env.DATABASE_URL, tempPath])
} finally {
source?.close()
await rm(tempPath, { force: true })
}
})
}
export function getSystemDatabasePath(): string {
return resolveDatabasePath()
return env.DATABASE_URL
}
+31 -35
View File
@@ -64,38 +64,36 @@ function toNum(raw: unknown): number {
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]
async function getSettingsRow() {
const row = (await db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1))[0]
if (row) return row
const now = new Date().toISOString()
db.insert(trafficSettings).values({
await 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]
})
return (await db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1))[0]
}
function cleanupOldSamples(retentionDays: number) {
async function cleanupOldSamples(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(trafficSamples)
await db.delete(trafficSamples)
.where(lt(trafficSamples.sampledAt, cutoff))
.run()
}
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
const last = db
async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>> {
const last = (await db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
.limit(1))[0]
if (!last) return new Map()
const rows = db
const rows = await db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
@@ -108,7 +106,6 @@ function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBy
eq(trafficSamples.serverId, serverId),
eq(trafficSamples.sampledAt, last.sampledAt),
))
.all()
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
}
@@ -126,7 +123,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
collecting = true
const startedAt = Date.now()
const now = sampledAt
const settings = getSettingsRow()
const settings = await getSettingsRow()
const snapshot: TrafficRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "traffic",
@@ -135,7 +132,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
}
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
for (const srv of enabledServers) {
const name = srv.name || srv.host
@@ -143,7 +140,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
rememberServerIfaces(srv.id, ifaces)
const prevWave = readPreviousWave(srv.id)
const prevWave = await readPreviousWave(srv.id)
const nowMs = Date.parse(now)
let sumRxMbps = 0
let sumTxMbps = 0
@@ -205,7 +202,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
/* WG peers optional — iface samples already recorded */
}
if (rows.length > 0) {
db.insert(trafficSamples).values(rows).run()
await db.insert(trafficSamples).values(rows)
}
snapshot.servers.push({
serverId: srv.id,
@@ -227,22 +224,22 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
}
}
cleanupOldSamples(Math.max(1, settings.retentionDays))
db.update(trafficSettings).set({
await cleanupOldSamples(Math.max(1, settings.retentionDays))
await db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: "",
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
}).where(eq(trafficSettings.id, 1))
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
snapshot.fatalError = msg
db.update(trafficSettings).set({
await db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: msg,
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
}).where(eq(trafficSettings.id, 1))
} finally {
collecting = false
}
@@ -254,8 +251,8 @@ export function stopTrafficCollector() {}
export function restartTrafficCollector() {}
export function getTrafficCollectorState(): TrafficCollectorState {
const s = getSettingsRow()
export async function getTrafficCollectorState(): Promise<TrafficCollectorState> {
const s = await getSettingsRow()
return {
running: Boolean(s.enabled),
lastRunAt: s.lastCollectedAt ?? null,
@@ -263,38 +260,37 @@ export function getTrafficCollectorState(): TrafficCollectorState {
}
}
/** Идёт активный прогон `collectTrafficOnce` (запись в SQLite ещё не завершена). */
/** Идёт активный прогон `collectTrafficOnce` (запись в PostgreSQL ещё не завершена). */
export function isTrafficCollecting(): boolean {
return collecting
}
export function getTrafficSettings() {
return getSettingsRow()
export async function getTrafficSettings() {
return await getSettingsRow()
}
export function updateTrafficSettings(patch: {
export async function updateTrafficSettings(patch: {
enabled?: boolean
intervalSec?: number
retentionDays?: number
}) {
const prev = getSettingsRow()
const prev = await 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()
return getSettingsRow()
await db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1))
return await getSettingsRow()
}
export function readServerSamplesInRange(serverId: number, sinceIso: string) {
return db.select()
export async function readServerSamplesInRange(serverId: number, sinceIso: string) {
return await db.select()
.from(trafficSamples)
.where(and(
eq(trafficSamples.serverId, serverId),
gte(trafficSamples.sampledAt, sinceIso),
))
.orderBy(asc(trafficSamples.sampledAt))
.all()
}
@@ -5,7 +5,8 @@ import {
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
import { sqliteDatabase } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
@@ -15,6 +16,11 @@ import {
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
if (!(await withPgOrSkip())) {
console.log("traffic-flow-analytics.test.ts: skip")
process.exit(0)
}
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
@@ -63,7 +69,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
const all = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const all = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(all.applications[0]?.label, "HTTPS")
assert.ok(all.protocols.some((p) => p.label === "TCP"))
assert.equal(all.ifaces[0]?.name, "ether1")
@@ -75,14 +81,14 @@ try {
assert.equal(conv.application, "HTTPS")
assert.ok(!/^\d+$/.test(conv.inIface))
const filtered = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" })
const filtered = await buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" })
assert.ok(filtered.bytes >= 12_000)
assert.equal(filtered.ifaces[0]?.name, "ether1")
const miss = buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" })
const miss = await buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" })
assert.equal(miss.conversations, 0)
const other = buildFlowAnalytics({ minutes: 5, serverId: 99 })
const other = await buildFlowAnalytics({ minutes: 5, serverId: 99 })
assert.equal(other.conversations, 0)
} finally {
resetFlowRingsForTests()
@@ -121,10 +127,10 @@ ingestParsedFlowsForServerForTests(7, [
},
])
try {
const summed = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false })
const summed = await buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false })
assert.equal(summed.bytes, 21_000)
assert.equal(summed.conversations, 2)
const deduped = buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true })
const deduped = await buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true })
assert.equal(deduped.bytes, 12_000)
assert.equal(deduped.conversations, 1)
assert.equal(deduped.dedupApplied, true)
@@ -165,7 +171,7 @@ ingestParsedFlowsForServerForTests(7, [
},
])
try {
const geo = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const geo = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(geo.categories?.[0]?.label, "Игры")
assert.ok(geo.asns?.some((r) => r.label.includes("AS15169")))
assert.equal(geo.countries?.[0]?.id, "US")
@@ -210,7 +216,7 @@ ingestParsedFlowsForServerForTests(7, [
},
])
try {
const cf = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const cf = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(cf.countries?.[0]?.id, "US")
assert.ok(cf.mapEdges?.every((e) => e.toCountry !== "?"))
assert.equal(cf.services?.[0]?.label, "Cloudflare")
@@ -238,7 +244,7 @@ try {
outIface: "",
},
])
const degraded = buildFlowAnalytics({ minutes: 5, serverId: 7, skipHeavy: true })
const degraded = await buildFlowAnalytics({ minutes: 5, serverId: 7, skipHeavy: true })
assert.equal(degraded.degraded, true)
assert.equal(degraded.conversationsList.length, 0)
assert.ok((degraded.bytes ?? 0) >= 12_000)
@@ -249,30 +255,35 @@ try {
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
const liveOk = formatLiveSseFromBuilder(() => ({ ok: true }))
assert.equal(liveOk.event, "sample")
const exporters = listFlowExporters(5)
const clients = listFlowClients(5)
const exporters = await listFlowExporters(5)
const clients = await listFlowClients(5)
assert.ok(Array.isArray(exporters.exporters))
assert.ok(Array.isArray(clients.clients))
resetFlowRingsForTests()
}
{
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
sqliteDatabase.exec(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES
(7, '2026-09-01', 'country', 'US', 1000, 10),
(7, '2026-09-02', 'country', 'US', 500, 5),
(7, '2026-09-01', 'service', 'steam', 800, 8),
(7, '2026-09-01', 'asn', '15169', 900, 9),
(7, '2026-09-01', 'asn', 'other', 100, 1)
`)
const monthly = getFlowMonthly("2026-09", 7)
assert.equal(monthly.bytes, 1500)
assert.equal(monthly.countries[0]?.id, "US")
assert.equal(monthly.countries[0]?.bytes, 1500)
assert.ok(monthly.asns.some((row) => row.id === "other"))
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
if (await withPgOrSkip()) {
const { pool } = await import("../db/index.js")
const { ensurePartitionFor } = await import("../db/partitions.js")
await ensurePartitionFor(pool, "flow_daily_dims", "month", new Date("2026-09-01T00:00:00Z"))
await dbQuery(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day >= '2026-09-01' AND day < '2026-10-01'`)
await dbQuery(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES
(7, '2026-09-01', 'country', 'US', 1000, 10),
(7, '2026-09-02', 'country', 'US', 500, 5),
(7, '2026-09-01', 'service', 'steam', 800, 8),
(7, '2026-09-01', 'asn', '15169', 900, 9),
(7, '2026-09-01', 'asn', 'other', 100, 1)
`)
const monthly = await getFlowMonthly("2026-09", 7)
assert.equal(monthly.bytes, 1500)
assert.equal(monthly.countries[0]?.id, "US")
assert.equal(monthly.countries[0]?.bytes, 1500)
assert.ok(monthly.asns.some((row) => row.id === "other"))
await dbQuery(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day >= '2026-09-01' AND day < '2026-10-01'`)
}
}
{
@@ -353,7 +364,7 @@ try {
},
])
try {
const def = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const def = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
assert.equal(def.bytes, 12_000)
assert.equal(def.bytesPayload, 12_000)
assert.equal(def.bytesOverlay, 5_000_000)
@@ -371,7 +382,7 @@ try {
assert.equal(path.clientName, "Alice")
assert.equal(path.enName, "NSK-SERVHOST-RTK")
assert.equal(path.dst, "173.194.160.163")
const withAll = buildFlowAnalytics({ minutes: 5, serverId: 7, excludeOverlay: false, excludeMesh: false })
const withAll = await buildFlowAnalytics({ minutes: 5, serverId: 7, excludeOverlay: false, excludeMesh: false })
assert.equal(withAll.bytes, 12_000 + 5_000_000 + 8000)
assert.ok(withAll.conversationsList.some((r) => r.plane === "overlay"))
assert.ok(withAll.conversationsList.some((r) => r.plane === "client_mesh"))
@@ -415,7 +426,7 @@ try {
},
])
try {
const rev = buildFlowAnalytics({ minutes: 5, serverId: 7 })
const rev = await buildFlowAnalytics({ minutes: 5, serverId: 7 })
const google = rev.conversationsList.find((r) => r.src === "173.194.151.65")
const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51")
assert.equal(google?.service, "Google")
@@ -430,37 +441,42 @@ try {
}
{
resetFlowRingsForTests()
resetIfaceCacheForTests()
const sidRow = sqliteDatabase.prepare(`SELECT id FROM servers LIMIT 1`).get() as { id?: number } | undefined
if (sidRow?.id) {
const sid = sidRow.id
rememberServerIfaces(sid, [{ ".id": "*4", name: "gre-en-nsk" }])
ingestParsedFlowsForServerForTests(sid, [{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 1,
dstPort: 443,
bytes: 100,
packets: 1,
inIface: "4",
outIface: "4",
}])
sqliteDatabase.prepare(`
INSERT INTO traffic_samples (server_id, interface_name, sampled_at, rx_bytes, tx_bytes, rx_bps, tx_bps)
VALUES (?, 'gre-en-nsk', datetime('now'), 9000000, 1000000, 40000000, 2000000)
`).run(sid)
try {
const wire = buildFlowAnalytics({ minutes: 5, serverId: sid })
assert.ok((wire.bpsWire ?? 0) >= 40_000_000)
assert.notEqual(wire.bpsWire, (wire.bytes * 8) / 300)
} finally {
sqliteDatabase.prepare(`DELETE FROM traffic_samples WHERE server_id = ? AND interface_name = 'gre-en-nsk'`).run(sid)
if (await withPgOrSkip()) {
resetFlowRingsForTests()
resetIfaceCacheForTests()
const sidRow = await dbQuery<{ id: number }>(`SELECT id FROM servers LIMIT 1`)
const sid = sidRow.rows[0]?.id
if (sid) {
const { pool } = await import("../db/index.js")
const { ensurePartitionFor } = await import("../db/partitions.js")
await ensurePartitionFor(pool, "traffic_samples", "week", new Date())
rememberServerIfaces(sid, [{ ".id": "*4", name: "gre-en-nsk" }])
ingestParsedFlowsForServerForTests(sid, [{
src: "10.100.1.17",
dst: "8.8.8.8",
proto: 6,
srcPort: 1,
dstPort: 443,
bytes: 100,
packets: 1,
inIface: "4",
outIface: "4",
}])
await dbQuery(`
INSERT INTO traffic_samples (server_id, interface_name, sampled_at, rx_bytes, tx_bytes, rx_bps, tx_bps)
VALUES ($1, 'gre-en-nsk', now(), 9000000, 1000000, 40000000, 2000000)
`, [sid])
try {
const wire = await buildFlowAnalytics({ minutes: 5, serverId: sid })
assert.ok((wire.bpsWire ?? 0) >= 40_000_000)
assert.notEqual(wire.bpsWire, (wire.bytes * 8) / 300)
} finally {
await dbQuery(`DELETE FROM traffic_samples WHERE server_id = $1 AND interface_name = 'gre-en-nsk'`, [sid])
}
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
}
resetFlowRingsForTests()
resetIfaceCacheForTests()
}
console.log("traffic-flow-analytics.test.ts: ok")
+26 -26
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js"
import { db, dbAll } from "../db/index.js"
import { appUsers, userInterfaceBindings } from "../db/schema.js"
import type {
FlowAnalyticsDto,
@@ -94,9 +94,9 @@ function bump(
map.set(id, prev)
}
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
async function userIfaceAllow(userId: string): Promise<Map<number, Set<string>> | null> {
if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
const binds = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
const allow = new Map<number, Set<string>>()
for (const b of binds) {
const set = allow.get(b.serverId) ?? new Set<string>()
@@ -142,13 +142,13 @@ function topLabel(map: Map<string, { bytes: number; packets: number; label?: str
return best
}
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
export async function buildFlowAnalytics(q: FlowAnalyticsQuery): Promise<FlowAnalyticsDto> {
const key = analyticsQueryKey(q)
const now = Date.now()
if (analyticsCache && analyticsCache.key === key && now - analyticsCache.at < ANALYTICS_CACHE_TTL_MS) {
return analyticsCache.dto
}
const dto = buildFlowAnalyticsUncached(q)
const dto = await buildFlowAnalyticsUncached(q)
analyticsCache = { key, at: now, dto }
return dto
}
@@ -174,20 +174,20 @@ function analyticsQueryKey(q: FlowAnalyticsQuery): string {
const ANALYTICS_CACHE_TTL_MS = 2000
let analyticsCache: { key: string; at: number; dto: FlowAnalyticsDto } | null = null
function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): FlowAnalyticsDto {
const settings = getTrafficFlowSettingsRow()
async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAnalyticsDto> {
const settings = await getTrafficFlowSettingsRow()
const top = Math.min(50, Math.max(10, settings.topN))
const windowSec = Math.max(60, q.minutes * 60)
const raw = listFlowRowsForWindow(q.minutes)
const allow = q.userId ? userIfaceAllow(q.userId) : null
const catalog = getServerCatalog()
const raw = await listFlowRowsForWindow(q.minutes)
const allow = q.userId ? await userIfaceAllow(q.userId) : null
const catalog = await getServerCatalog()
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
const countryById = new Map([...catalog.byId].map(([id, s]) => [id, s.country]))
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
const wantDedup = q.dedup !== false && !ifaceFilter
const excludeMesh = q.excludeMesh !== false
const excludeOverlay = q.excludeOverlay !== false
const topo = loadFlowTopology()
const topo = await loadFlowTopology()
refreshFlowCatalogInBackground()
@@ -447,7 +447,7 @@ function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): FlowAnalyticsDto {
? getRingMbps(ringServer, RING_OVERLAY)
: { rxNow: 0, txNow: 0 }
const greNames = ringServer ? enGreIfaceNames(topo, ringServer, [...ifacesForWire]) : []
const wire = ringServer ? latestWireBps(ringServer, greNames) : { bps: 0, bytes: 0 }
const wire = ringServer ? await latestWireBps(ringServer, greNames) : { bps: 0, bytes: 0 }
return {
bpsNow: (ring.rxNow + ring.txNow) * 1_000_000 || (totalBytes * 8) / windowSec,
@@ -505,13 +505,13 @@ function summarizeByServer(rows: PendingFlowRow[]) {
return { bytes, sessions }
}
export function listFlowExporters(minutes: number): FlowExportersDto {
const runtime = getFlowRuntimeCounters()
const rows = listFlowRowsForWindow(minutes)
export async function listFlowExporters(minutes: number): Promise<FlowExportersDto> {
const runtime = await getFlowRuntimeCounters()
const rows = await listFlowRowsForWindow(minutes)
const { bytes, sessions } = summarizeByServer(rows)
const ids = new Set<number>([...bytes.keys()])
for (const p of listHostPeers()) ids.add(p.serverId)
const catalog = getServerCatalog()
for (const p of await listHostPeers()) ids.add(p.serverId)
const catalog = await getServerCatalog()
const emptySeries = Array(60).fill(0) as number[]
const exporters = catalog.list
.filter((s) => ids.has(s.id))
@@ -546,16 +546,16 @@ export function listFlowExporters(minutes: number): FlowExportersDto {
}
}
export function listFlowClients(minutes: number): FlowClientsDto {
const users = db.select().from(appUsers).all()
const binds = db.select().from(userInterfaceBindings).all()
export async function listFlowClients(minutes: number): Promise<{ clients: { id: string; name: string; subtitle: string; site: string; country: string; status: "online" | "offline" | "degraded"; rxNow: number; txNow: number; sessions: number; rxSeries: number[]; txSeries: number[]; bytes: number; }[]; }> {
const users = await db.select().from(appUsers)
const binds = await db.select().from(userInterfaceBindings)
const byUser = new Map<string, typeof binds>()
for (const b of binds) {
const list = byUser.get(b.userId) ?? []
list.push(b)
byUser.set(b.userId, list)
}
const rows = listFlowRowsForWindow(minutes)
const rows = await listFlowRowsForWindow(minutes)
const emptySeries = Array(60).fill(0) as number[]
const windowSec = Math.max(60, minutes * 60)
const clients: FlowEntityCard[] = []
@@ -617,9 +617,9 @@ export function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" |
event: "sample" | "error"
data: unknown
} {
return formatLiveSseFromBuilder(() => {
return formatLiveSseFromBuilder(async () => {
const skipHeavy = isFlowAnalyticsDegraded()
return buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
return await buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
})
}
@@ -653,7 +653,7 @@ function toBreakdown(
}))
}
export function getFlowMonthly(month: string, serverId?: number): FlowMonthlyDto {
export async function getFlowMonthly(month: string, serverId?: number): Promise<FlowMonthlyDto> {
const bounds = monthBounds(month)
if (!bounds) {
return { month, bytes: 0, countries: [], services: [], asns: [] }
@@ -664,12 +664,12 @@ export function getFlowMonthly(month: string, serverId?: number): FlowMonthlyDto
where += " AND server_id = ?"
params.push(serverId)
}
const rows = sqliteDatabase.prepare(`
const rows = await dbAll<{ dim: string; key: string; bytes: number; packets: number }>(`
SELECT dim AS dim, key AS key, SUM(bytes) AS bytes, SUM(packets) AS packets
FROM flow_daily_dims
WHERE ${where}
GROUP BY dim, key
`).all(...params) as Array<{ dim: string; key: string; bytes: number; packets: number }>
`, params)
const countries: Array<{ key: string; bytes: number; packets: number }> = []
const services: Array<{ key: string; bytes: number; packets: number }> = []
@@ -97,7 +97,7 @@ async function fetchCatalog(): Promise<void> {
if (inflight) return inflight
inflight = (async () => {
try {
const row = db.select().from(evobgpSettings).limit(1).all()[0]
const row = (await db.select().from(evobgpSettings).limit(1))[0]
if (!row?.enabled) return
const root = String(row.baseUrl ?? "").replace(/\/+$/, "")
const token = String(row.apiKey ?? "").replace(/^Bearer\s+/i, "").trim()
@@ -8,7 +8,6 @@ export interface ExporterMapPayload {
}
export interface CollectorStartPayload {
dbPath: string
listenHost: string
listenPort: number
topN: number
@@ -1,6 +1,5 @@
import { createSocket, type Socket } from "node:dgram"
import { parentPort } from "node:worker_threads"
import { sqliteDatabase } from "../db/index.js"
import type {
CollectorStartPayload,
ExporterMapPayload,
@@ -9,7 +8,6 @@ import type {
} from "./traffic-flow-collector-ipc.js"
import {
TICK_MS,
attachEngineSqlite,
configureEngine,
flushPending,
getEngineStats,
@@ -23,7 +21,6 @@ let socket: Socket | null = null
let flushTimer: ReturnType<typeof setInterval> | null = null
let bound = false
let address: string | null = null
let attached = false
function send(msg: WorkerToMain): void {
parentPort?.postMessage(msg)
@@ -58,22 +55,14 @@ function applyExporterMap(payload: ExporterMapPayload): void {
})
}
function ensureSqlite(): void {
if (attached) return
attachEngineSqlite(sqliteDatabase)
attached = true
}
function stopListener(): void {
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = null
}
try {
flushPending()
} catch (e) {
void flushPending().catch((e) => {
setEngineError(e instanceof Error ? e.message : String(e))
}
})
if (socket) {
try { socket.close() } catch { /* ignore */ }
socket = null
@@ -84,7 +73,6 @@ function stopListener(): void {
function startListener(payload: CollectorStartPayload): void {
stopListener()
ensureSqlite()
configureEngine({ topN: payload.topN, retentionHours: payload.retentionHours })
applyExporterMap(payload.exporterMap)
@@ -116,12 +104,9 @@ function startListener(payload: CollectorStartPayload): void {
})
socket = sock
flushTimer = setInterval(() => {
try {
flushPending()
} catch (e) {
void flushPending().catch((e) => {
setEngineError(e instanceof Error ? e.message : String(e))
}
heartbeat()
}).finally(() => heartbeat())
}, TICK_MS)
}
+157 -174
View File
@@ -1,4 +1,5 @@
import type Database from "better-sqlite3"
import { dbAll, dbQuery, pool } from "../db/index.js"
import { ensurePartitionFor, specForParent } from "../db/partitions.js"
import { normalizeParsedFlow, parseFlowPacket, protoName, type ParsedFlow, type ParsedFlowInput } from "./traffic-flow-parse.js"
import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
@@ -10,8 +11,6 @@ import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
import { pickInternetPeer } from "./traffic-flow-ip.js"
type SqliteHandle = InstanceType<typeof Database>
export const TICK_MS = 2_000
export const RING_LEN = 60
export const MAX_PENDING = 50_000
@@ -20,6 +19,17 @@ export const DAILY_RETENTION_DAYS = 396
export const MINUTE_RETENTION_HOURS = 48
let pendingCap = MAX_PENDING
const ensuredParts = new Set<string>()
async function ensureParentPartition(parent: string, ts: string): Promise<void> {
const spec = specForParent(parent)
if (!spec) return
const iso = ts.length === 10 ? `${ts}T00:00:00Z` : ts
const key = `${parent}:${iso.slice(0, 10)}`
if (ensuredParts.has(key)) return
await ensurePartitionFor(pool, parent, spec.kind, new Date(iso))
ensuredParts.add(key)
}
export interface PendingFlowRow {
serverId: number
@@ -76,7 +86,6 @@ export interface ExporterResolveCtx {
hostIps: Map<string, number>
}
let sqliteRef: SqliteHandle | null = null
let topN = 200
let retentionHours = 24
@@ -95,7 +104,7 @@ let dropped = 0
let rowsStored = 0
let lastFlushUsedTransaction = false
let lastPruneAt = 0
let lastPassiveCheckpointAt = Date.now()
let lastPassiveCheckpointAt = 0
let dataEpoch = 0
let lastPersistedStats: {
packetsReceived: number
@@ -107,7 +116,6 @@ let exporterCtx: ExporterResolveCtx | null = null
const PRUNE_MS = 5 * 60_000
const LIVE_WINDOW_MS = 15 * 60_000
const PASSIVE_CHECKPOINT_MS = 60_000
function bumpDataEpoch(): void {
dataEpoch += 1
@@ -201,8 +209,8 @@ function bumpRollup(serverId: number, bucketAt: string, flow: ParsedFlow, bytes:
acc.conversations += 1
}
export function attachEngineSqlite(handle: SqliteHandle): void {
sqliteRef = handle
export function attachEngineSqlite(_handle?: unknown): void {
/* engine uses pg pool via dbQuery/dbAll */
}
export function setPendingCapForTests(n: number | null): void {
@@ -441,7 +449,7 @@ export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; ou
}
}
function persistListenerStats(handle: SqliteHandle): boolean {
async function persistListenerStats(): Promise<boolean> {
if (
lastPersistedStats
&& lastPersistedStats.packetsReceived === packetsReceived
@@ -451,7 +459,7 @@ function persistListenerStats(handle: SqliteHandle): boolean {
) {
return false
}
handle.prepare(`
await dbQuery(`
UPDATE traffic_flow_settings
SET packets_received = @packetsReceived,
last_datagram_at = @lastDatagramAt,
@@ -459,7 +467,7 @@ function persistListenerStats(handle: SqliteHandle): boolean {
last_error = @lastError,
updated_at = @updatedAt
WHERE id = 1
`).run({
`, {
packetsReceived,
lastDatagramAt,
lastExporterIp,
@@ -476,110 +484,85 @@ function persistListenerStats(handle: SqliteHandle): boolean {
return true
}
function maybePassiveCheckpoint(handle: SqliteHandle): void {
const now = Date.now()
if (now - lastPassiveCheckpointAt < PASSIVE_CHECKPOINT_MS) return
lastPassiveCheckpointAt = now
try {
handle.pragma("wal_checkpoint(PASSIVE)")
} catch {
/* ignore */
async function upsertMinuteAndDaily(): Promise<void> {
for (const [k, acc] of minuteRollup) {
const [serverIdRaw, bucketAt] = k.split("\0")
await ensureParentPartition("flow_minute_stats", bucketAt ?? "")
await dbQuery(`
INSERT INTO flow_minute_stats (
server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations
) VALUES (
@serverId, @bucketAt, @bytes, @packets, @uniqueSrc, @uniqueDst, @conversations
)
ON CONFLICT(server_id, bucket_at) DO UPDATE SET
bytes = flow_minute_stats.bytes + excluded.bytes,
packets = flow_minute_stats.packets + excluded.packets,
unique_src = GREATEST(flow_minute_stats.unique_src, excluded.unique_src),
unique_dst = GREATEST(flow_minute_stats.unique_dst, excluded.unique_dst),
conversations = flow_minute_stats.conversations + excluded.conversations
`, {
serverId: Number(serverIdRaw),
bucketAt,
bytes: acc.bytes,
packets: acc.packets,
uniqueSrc: acc.srcs.size,
uniqueDst: acc.dsts.size,
conversations: acc.conversations,
})
}
}
function upsertMinuteAndDaily(handle: SqliteHandle): void {
const upsertMinute = handle.prepare(`
INSERT INTO flow_minute_stats (
server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations
) VALUES (
@serverId, @bucketAt, @bytes, @packets, @uniqueSrc, @uniqueDst, @conversations
)
ON CONFLICT(server_id, bucket_at) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets,
unique_src = MAX(unique_src, excluded.unique_src),
unique_dst = MAX(unique_dst, excluded.unique_dst),
conversations = conversations + excluded.conversations
`)
const upsertDim = handle.prepare(`
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
VALUES (@serverId, @bucketAt, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, bucket_at, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
const upsertDaily = handle.prepare(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (@serverId, @day, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
const tx = handle.transaction(() => {
for (const [k, acc] of minuteRollup) {
const [serverIdRaw, bucketAt] = k.split("\0")
upsertMinute.run({
for (const [k, acc] of minuteDims) {
const [serverIdRaw, bucketAt, dim, key] = k.split("\0")
await ensureParentPartition("flow_minute_dims", bucketAt ?? "")
await ensureParentPartition("flow_daily_dims", dayKey(bucketAt ?? ""))
await dbQuery(`
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
VALUES (@serverId, @bucketAt, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, bucket_at, dim, key) DO UPDATE SET
bytes = flow_minute_dims.bytes + excluded.bytes,
packets = flow_minute_dims.packets + excluded.packets
`, {
serverId: Number(serverIdRaw),
bucketAt,
dim,
key,
bytes: acc.bytes,
packets: acc.packets,
})
if (dim === "country" || dim === "service" || dim === "asn") {
await dbQuery(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (@serverId, @day, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = flow_daily_dims.bytes + excluded.bytes,
packets = flow_daily_dims.packets + excluded.packets
`, {
serverId: Number(serverIdRaw),
bucketAt,
bytes: acc.bytes,
packets: acc.packets,
uniqueSrc: acc.srcs.size,
uniqueDst: acc.dsts.size,
conversations: acc.conversations,
})
}
for (const [k, acc] of minuteDims) {
const [serverIdRaw, bucketAt, dim, key] = k.split("\0")
upsertDim.run({
serverId: Number(serverIdRaw),
bucketAt,
day: dayKey(bucketAt ?? ""),
dim,
key,
bytes: acc.bytes,
packets: acc.packets,
})
if (dim === "country" || dim === "service" || dim === "asn") {
upsertDaily.run({
serverId: Number(serverIdRaw),
day: dayKey(bucketAt ?? ""),
dim,
key,
bytes: acc.bytes,
packets: acc.packets,
})
}
}
})
tx()
}
minuteRollup.clear()
minuteDims.clear()
}
function capDailyAsn(handle: SqliteHandle): void {
async function capDailyAsn(): Promise<void> {
const today = nowIso().slice(0, 10)
const rows = handle.prepare(`
SELECT server_id AS serverId, key, bytes, packets
const rows = await dbAll<{ serverId: number; key: string; bytes: number; packets: number }>(`
SELECT server_id AS "serverId", key, bytes, packets
FROM flow_daily_dims
WHERE day = ? AND dim = 'asn'
ORDER BY server_id, bytes DESC
`).all(today) as Array<{ serverId: number; key: string; bytes: number; packets: number }>
`, [today])
const byServer = new Map<number, typeof rows>()
for (const row of rows) {
const list = byServer.get(row.serverId) ?? []
list.push(row)
byServer.set(row.serverId, list)
}
const del = handle.prepare(`
DELETE FROM flow_daily_dims WHERE server_id = ? AND day = ? AND dim = 'asn' AND key = ?
`)
const upsertOther = handle.prepare(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (?, ?, 'asn', 'other', ?, ?)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
for (const [serverId, list] of byServer) {
if (list.length <= DAILY_ASN_TOP) continue
let otherBytes = 0
@@ -588,52 +571,60 @@ function capDailyAsn(handle: SqliteHandle): void {
if (row.key === "other") continue
otherBytes += row.bytes
otherPackets += row.packets
del.run(serverId, today, row.key)
await dbQuery(
`DELETE FROM flow_daily_dims WHERE server_id = ? AND day = ? AND dim = 'asn' AND key = ?`,
[serverId, today, row.key],
)
}
if (otherBytes > 0) {
await ensureParentPartition("flow_daily_dims", today)
await dbQuery(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (?, ?, 'asn', 'other', ?, ?)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = flow_daily_dims.bytes + excluded.bytes,
packets = flow_daily_dims.packets + excluded.packets
`, [serverId, today, otherBytes, otherPackets])
}
if (otherBytes > 0) upsertOther.run(serverId, today, otherBytes, otherPackets)
}
}
function pruneStored(handle: SqliteHandle): void {
async function pruneStored(): Promise<void> {
const now = Date.now()
if (now - lastPruneAt < PRUNE_MS) return
lastPruneAt = now
const flowCutoff = new Date(now - retentionHours * 3600_000).toISOString()
const minuteCutoff = new Date(now - MINUTE_RETENTION_HOURS * 3600_000).toISOString()
const dailyCutoff = new Date(now - DAILY_RETENTION_DAYS * 86400_000).toISOString().slice(0, 10)
handle.prepare(`DELETE FROM flow_buckets WHERE bucket_at < ?`).run(flowCutoff)
handle.prepare(`DELETE FROM flow_minute_stats WHERE bucket_at < ?`).run(minuteCutoff)
handle.prepare(`DELETE FROM flow_minute_dims WHERE bucket_at < ?`).run(minuteCutoff)
handle.prepare(`DELETE FROM flow_daily_dims WHERE day < ?`).run(dailyCutoff)
await dbQuery(`DELETE FROM flow_buckets WHERE bucket_at < ?`, [flowCutoff])
await dbQuery(`DELETE FROM flow_minute_stats WHERE bucket_at < ?`, [minuteCutoff])
await dbQuery(`DELETE FROM flow_minute_dims WHERE bucket_at < ?`, [minuteCutoff])
await dbQuery(`DELETE FROM flow_daily_dims WHERE day < ?`, [dailyCutoff])
const keep = Math.max(20, topN)
try {
handle.prepare(`
DELETE FROM flow_buckets WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
) AS rn
FROM flow_buckets
) ranked WHERE rn > ?
)
`).run(keep)
} catch {
const buckets = handle.prepare(`
SELECT DISTINCT server_id AS serverId, bucket_at AS bucketAt FROM flow_buckets
`).all() as Array<{ serverId: number; bucketAt: string }>
for (const b of buckets) {
const rows = handle.prepare(`
SELECT id, bytes FROM flow_buckets
WHERE server_id = ? AND bucket_at = ?
ORDER BY bytes DESC
`).all(b.serverId, b.bucketAt) as Array<{ id: number; bytes: number }>
for (const extra of rows.slice(keep)) {
handle.prepare(`DELETE FROM flow_buckets WHERE id = ?`).run(extra.id)
}
}
}
pruneRipeSqlite(now)
await dbQuery(`
DELETE FROM flow_buckets fb
USING (
SELECT server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface
FROM (
SELECT server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface,
ROW_NUMBER() OVER (
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
) AS rn
FROM flow_buckets
) ranked
WHERE rn > ?
) drop_rows
WHERE fb.server_id = drop_rows.server_id
AND fb.bucket_at = drop_rows.bucket_at
AND fb.src = drop_rows.src
AND fb.dst = drop_rows.dst
AND fb.proto = drop_rows.proto
AND fb.src_port = drop_rows.src_port
AND fb.dst_port = drop_rows.dst_port
AND fb.in_iface = drop_rows.in_iface
`, [keep])
await pruneRipeSqlite(now)
}
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
@@ -653,18 +644,12 @@ function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
return out
}
export function flushPending(): void {
export async function flushPending(): Promise<void> {
pruneRecent()
rollFlowRings()
const handle = sqliteRef
if (!handle) {
lastFlushUsedTransaction = false
return
}
persistListenerStats(handle)
await persistListenerStats()
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
pruneStored(handle)
maybePassiveCheckpoint(handle)
await pruneStored()
lastFlushUsedTransaction = false
return
}
@@ -672,7 +657,7 @@ export function flushPending(): void {
pending.clear()
for (const row of rows) mergeInto(recent, row)
const upsertFlow = handle.prepare(`
const upsertSql = `
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
) VALUES (
@@ -680,45 +665,44 @@ export function flushPending(): void {
)
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets,
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE out_iface END,
next_hop = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE next_hop END,
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 = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE flow_buckets.next_hop END,
flow_start_ms = CASE
WHEN excluded.flow_start_ms > 0 AND (flow_start_ms = 0 OR excluded.flow_start_ms < flow_start_ms)
THEN excluded.flow_start_ms ELSE flow_start_ms END,
flow_end_ms = MAX(flow_end_ms, excluded.flow_end_ms)
`)
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)
`
lastFlushUsedTransaction = false
try {
const tx = handle.transaction((batch: PendingFlowRow[]) => {
for (const r of batch) {
upsertFlow.run({
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
outIface: r.outIface,
nextHop: r.nextHop,
flowStartMs: r.flowStartMs,
flowEndMs: r.flowEndMs,
})
}
})
tx(rows)
for (const r of rows) {
await ensureParentPartition("flow_buckets", r.bucketAt)
await dbQuery(upsertSql, {
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
outIface: r.outIface,
nextHop: r.nextHop,
flowStartMs: r.flowStartMs,
flowEndMs: r.flowEndMs,
})
}
lastFlushUsedTransaction = true
rowsStored += rows.length
bumpDataEpoch()
} catch {
for (const r of rows) {
try {
upsertFlow.run({
await ensureParentPartition("flow_buckets", r.bucketAt)
await dbQuery(upsertSql, {
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
@@ -741,25 +725,24 @@ export function flushPending(): void {
}
}
try {
upsertMinuteAndDaily(handle)
capDailyAsn(handle)
await upsertMinuteAndDaily()
await capDailyAsn()
} catch {
/* rollup best-effort */
}
pruneStored(handle)
maybePassiveCheckpoint(handle)
await pruneStored()
}
export function lastFlushUsedTransactionForTests(): boolean {
return lastFlushUsedTransaction
}
export function flushPendingForTests(): void {
flushPending()
export async function flushPendingForTests(): Promise<void> {
await flushPending()
}
export function onEngineTick(): void {
flushPending()
void flushPending()
}
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]): void {
@@ -1,38 +0,0 @@
import assert from "node:assert/strict"
import {
SQLITE_BUSY_TIMEOUT_MS,
SQLITE_CACHE_SIZE_KIB,
SQLITE_WAL_AUTOCHECKPOINT_PAGES,
sqliteDatabase,
} from "../db/index.js"
import {
MAX_FLOW_LIVE_SUBSCRIBERS,
resetFlowLiveSlotsForTests,
tryAcquireFlowLiveSlot,
releaseFlowLiveSlot,
} from "../routes/traffic-flow.js"
const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: number }>
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
function pragmaNum(name: string): number {
const rows = sqliteDatabase.pragma(name) as Array<Record<string, number>>
const row = Array.isArray(rows) ? rows[0] : rows
return Number(Object.values(row ?? {})[0])
}
assert.equal(pragmaNum("wal_autocheckpoint"), SQLITE_WAL_AUTOCHECKPOINT_PAGES)
assert.equal(pragmaNum("cache_size"), -SQLITE_CACHE_SIZE_KIB)
assert.equal(pragmaNum("temp_store"), 2)
resetFlowLiveSlotsForTests()
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
assert.equal(tryAcquireFlowLiveSlot(), true)
}
assert.equal(tryAcquireFlowLiveSlot(), false)
releaseFlowLiveSlot()
assert.equal(tryAcquireFlowLiveSlot(), true)
resetFlowLiveSlotsForTests()
console.log("traffic-flow-hardening.test.ts: ok")
+13 -13
View File
@@ -4,9 +4,9 @@ import type { TrafficFlowHostFile } from "@mmapp/contracts/traffic-flow"
const COMPOSE_DIR = "/opt/cdn-mm"
export function buildHostWgQuickConf(): string {
const row = getTrafficFlowSettingsRow()
const peers = listHostPeers()
export async function buildHostWgQuickConf(): Promise<string> {
const row = await getTrafficFlowSettingsRow()
const peers = await listHostPeers()
return generateNativeConf({
name: "wg-flow",
mtu: 1420,
@@ -23,8 +23,8 @@ export function buildHostWgQuickConf(): string {
})
}
export function buildHostComposeOverride(): string {
const row = getTrafficFlowSettingsRow()
export async function buildHostComposeOverride(): Promise<string> {
const row = await getTrafficFlowSettingsRow()
return [
"# Docker Compose merge для /opt/cdn-mm",
"# Не править docker-compose.yml. Traefik не трогать.",
@@ -42,10 +42,10 @@ export function buildHostComposeOverride(): string {
].join("\n")
}
export function buildHostLinuxInstallSh(): string {
const row = getTrafficFlowSettingsRow()
const conf = buildHostWgQuickConf().replace(/\s+$/, "") + "\n"
const override = buildHostComposeOverride()
export async function buildHostLinuxInstallSh(): Promise<string> {
const row = await getTrafficFlowSettingsRow()
const conf = (await buildHostWgQuickConf()).replace(/\s+$/, "") + "\n"
const override = await buildHostComposeOverride()
const collector = row.collectorIp
const flowPort = row.flowListenPort
@@ -104,10 +104,10 @@ echo "Готово. Traefik не трогали. UDP \${FLOW_PORT} только
`
}
export function listTrafficFlowHostFiles(): TrafficFlowHostFile[] {
export async function listTrafficFlowHostFiles(): Promise<TrafficFlowHostFile[]> {
return [
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: buildHostLinuxInstallSh() },
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: buildHostWgQuickConf() },
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: buildHostComposeOverride() },
{ id: "linux", label: "Linux", filename: "install-wg-flow.sh", code: await buildHostLinuxInstallSh() },
{ id: "wg-quick", label: "wg-flow.conf", filename: "wg-flow.conf", code: await buildHostWgQuickConf() },
{ id: "compose", label: "compose", filename: "docker-compose.override.yml", code: await buildHostComposeOverride() },
]
}
+1 -1
View File
@@ -28,7 +28,7 @@ async function refreshServerIfacesInner(serverId: number, force = false): Promis
if (!force && !shouldRefreshIfaces(serverId)) return
inflight.add(serverId)
try {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row) return
const client = MikrotikClient.fromServer(row)
const ifaces = await client.get<RosIfaceIndexRow[]>("/interface")
@@ -22,7 +22,8 @@ import {
simulateWorkerExitForTests,
} from "./traffic-flow-ingest.js"
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
import { sqliteDatabase } from "../db/index.js"
import { dbQuery } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
resetIfaceCacheForTests()
resetFlowRingsForTests()
@@ -81,16 +82,23 @@ const talkers = Array.from({ length: 25 }, (_, i) => ({
inIface: "2",
outIface: "",
}))
ingestParsedFlowsForServerForTests(9, talkers)
flushPendingForTests()
const stored = sqliteDatabase.prepare(`
SELECT COUNT(*) AS n FROM flow_buckets WHERE server_id = 9
`).get() as { n: number }
assert.ok(stored.n <= 20, `expected topN cap, got ${stored.n}`)
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
if (await withPgOrSkip()) {
await dbQuery(`
INSERT INTO servers (id, name, host) VALUES (91009, 'flow-ingest-test', '127.0.0.1')
ON CONFLICT (id) DO NOTHING
`)
ingestParsedFlowsForServerForTests(91009, talkers)
await flushPendingForTests()
const stored = await dbQuery<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM flow_buckets WHERE server_id = 91009`,
)
assert.ok((stored.rows[0]?.n ?? 99) <= 20, `expected topN cap, got ${stored.rows[0]?.n}`)
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = 91009`)
await dbQuery(`DELETE FROM flow_minute_stats WHERE server_id = 91009`)
await dbQuery(`DELETE FROM flow_minute_dims WHERE server_id = 91009`)
await dbQuery(`DELETE FROM flow_daily_dims WHERE server_id = 91009`)
await dbQuery(`DELETE FROM servers WHERE id = 91009`)
}
applyHeartbeatForTests({
bound: true,
@@ -106,7 +114,7 @@ applyHeartbeatForTests({
rings: [],
})
assert.equal(getFlowListenerState().bound, true)
assert.equal(getFlowRuntimeCounters().packetsReceived, 42)
assert.equal((await getFlowRuntimeCounters()).packetsReceived, 42)
assert.equal(getFlowWorkerHealth().alive, false)
setWantListenForTests(true)
assert.equal(simulateWorkerExitForTests(), 1)
+93 -118
View File
@@ -1,15 +1,12 @@
import { Worker } from "node:worker_threads"
import { existsSync, statSync } from "node:fs"
import path from "node:path"
import { gte, sql } from "drizzle-orm"
import { beginSqliteExclusiveOp, db, endSqliteExclusiveOp, sqliteDatabase } from "../db/index.js"
import { env } from "../config.js"
import { db, dbGet, dbQuery, pool, withAdvisoryLock } from "../db/index.js"
import { dropExpiredPartitions } from "../db/partitions.js"
import { flowBuckets, servers } from "../db/schema.js"
import type { FlowPurgeDto, FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { protoName, type ParsedFlowInput } from "./traffic-flow-parse.js"
import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
import {
attachEngineSqlite,
applyRingSnapshot,
configureEngine,
flushPending,
@@ -57,8 +54,6 @@ let lastHeartbeat: CollectorHeartbeat | null = null
let state: FlowListenerState = { bound: false, address: null }
let wantListen = false
attachEngineSqlite(sqliteDatabase)
function workerFileUrl(): URL {
const ts = import.meta.url.includes(".ts")
return new URL(
@@ -67,13 +62,13 @@ function workerFileUrl(): URL {
)
}
export function buildExporterMapPayload(): ExporterMapPayload {
const settings = getTrafficFlowSettingsRow()
const rows = db.select({
export async function buildExporterMapPayload(): Promise<ExporterMapPayload> {
const settings = await getTrafficFlowSettingsRow()
const rows = await db.select({
id: servers.id,
host: servers.host,
mgmtTunnelIp: servers.mgmtTunnelIp,
}).from(servers).all()
}).from(servers)
const byTunnelIp: Array<[string, number]> = []
const hostIps: Array<[string, number]> = []
for (const row of rows) {
@@ -83,13 +78,13 @@ export function buildExporterMapPayload(): ExporterMapPayload {
return {
overlayPrefix: settings.prefix,
byTunnelIp,
peers: listHostPeers(),
peers: await listHostPeers(),
hostIps,
}
}
function applyExporterCtxFromDb(): void {
const payload = buildExporterMapPayload()
async function applyExporterCtxFromDb(): Promise<void> {
const payload = await buildExporterMapPayload()
setExporterResolveCtx({
overlayPrefix: payload.overlayPrefix,
byTunnelIp: new Map(payload.byTunnelIp),
@@ -117,11 +112,11 @@ function handleWorkerMessage(msg: WorkerToMain): void {
}
}
function spawnWorker(): void {
async function spawnWorker(): Promise<void> {
stopWorkerProcess()
const settings = getTrafficFlowSettingsRow()
const settings = await getTrafficFlowSettingsRow()
configureEngine({ topN: settings.topN, retentionHours: settings.retentionHours })
applyExporterCtxFromDb()
await applyExporterCtxFromDb()
const w = new Worker(workerFileUrl(), { execArgv: process.execArgv })
w.on("message", (msg: WorkerToMain) => handleWorkerMessage(msg))
w.on("error", (err) => {
@@ -149,7 +144,7 @@ function spawnWorker(): void {
const delay = Math.min(30_000, 1000 * 2 ** restartAttempts)
restartAttempts += 1
restartTimer = setTimeout(() => {
if (wantListen) spawnWorker()
if (wantListen) void spawnWorker()
}, delay)
void code
})
@@ -158,12 +153,11 @@ function spawnWorker(): void {
postToWorker({
type: "start",
payload: {
dbPath: env.DATABASE_PATH,
listenHost: host,
listenPort: settings.flowListenPort,
topN: settings.topN,
retentionHours: settings.retentionHours,
exporterMap: buildExporterMapPayload(),
exporterMap: await buildExporterMapPayload(),
},
})
}
@@ -184,8 +178,24 @@ function stopWorkerProcess(): void {
}
}
async function stopWorkerProcessAsync(): Promise<void> {
if (restartTimer) {
clearTimeout(restartTimer)
restartTimer = null
}
if (!worker) return
const current = worker
worker = null
try {
current.postMessage({ type: "stop" })
await current.terminate()
} catch {
/* ignore */
}
}
export function reattachFlowSqlite(): void {
attachEngineSqlite(sqliteDatabase)
/* no-op: engine uses PostgreSQL pool */
}
export function applyHeartbeatForTests(payload: CollectorHeartbeat): void {
@@ -221,44 +231,49 @@ export function getFlowWorkerHealth(): FlowWorkerHealth {
}
}
export function getFlowRuntimeCounters() {
const settings = getTrafficFlowSettingsRow()
export async function getFlowRuntimeCounters() {
const hb = lastHeartbeat
if (hb) {
return {
packetsReceived: hb.packetsReceived,
lastExporterIp: hb.lastExporterIp ?? null,
lastError: hb.lastError || null,
lastDatagramAt: hb.lastDatagramAt ?? null,
dropped: hb.dropped ?? 0,
}
}
const settings = await getTrafficFlowSettingsRow()
return {
packetsReceived: hb?.packetsReceived ?? settings.packetsReceived,
lastExporterIp: hb?.lastExporterIp ?? settings.lastExporterIp ?? null,
lastError: (hb?.lastError ?? settings.lastError) || null,
lastDatagramAt: hb?.lastDatagramAt ?? settings.lastDatagramAt ?? null,
dropped: hb?.dropped ?? 0,
packetsReceived: settings.packetsReceived,
lastExporterIp: settings.lastExporterIp ?? null,
lastError: settings.lastError || null,
lastDatagramAt: settings.lastDatagramAt ?? null,
dropped: 0,
}
}
export function startTrafficFlowListener() {
export async function startTrafficFlowListener() {
stopTrafficFlowListener()
const settings = getTrafficFlowSettingsRow()
const settings = await getTrafficFlowSettingsRow()
if (!settings.enabled) {
wantListen = false
state = { bound: false, address: null }
return
}
wantListen = true
spawnWorker()
await spawnWorker()
}
export function stopTrafficFlowListener() {
wantListen = false
stopWorkerProcess()
try {
flushPending()
} catch {
/* ignore */
}
void flushPending().catch(() => { /* ignore */ })
state = { bound: false, address: null }
}
export function refreshFlowExporterMap(): void {
applyExporterCtxFromDb()
postToWorker({ type: "updateExporterMap", payload: buildExporterMapPayload() })
export async function refreshFlowExporterMap(): Promise<void> {
await applyExporterCtxFromDb()
postToWorker({ type: "updateExporterMap", payload: await buildExporterMapPayload() })
}
export function getRingMbps(serverId: number, iface = "__all__") {
@@ -280,21 +295,20 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
map.set(key, { ...row })
}
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
export async function listLiveFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
if (worker && lastHeartbeat?.workerAlive) {
return listStoredFlowRows(sinceIso)
return await listStoredFlowRows(sinceIso)
}
return engineListLive(sinceIso)
}
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
const settings = getTrafficFlowSettingsRow()
export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowRow[]> {
const settings = await getTrafficFlowSettingsRow()
const cap = Math.max(20, settings.topN) * 60
const stored = db.select().from(flowBuckets)
const stored = await db.select().from(flowBuckets)
.where(gte(flowBuckets.bucketAt, sinceIso))
.orderBy(sql`${flowBuckets.bytes} DESC`)
.limit(cap)
.all()
const merged = new Map<string, PendingFlowRow>()
for (const r of stored) {
mergeInto(merged, {
@@ -323,17 +337,17 @@ export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
return [...merged.values()]
}
export function listFlowRowsForWindow(minutes: number): PendingFlowRow[] {
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
if (minutes <= 15 && !worker) return listLiveFlowRows(sinceIso)
return listStoredFlowRows(sinceIso)
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
return await listStoredFlowRows(sinceIso)
}
export function listFlowTalkers(minutes = 5): FlowStatsDto {
const settings = getTrafficFlowSettingsRow()
const runtime = getFlowRuntimeCounters()
const rows = listFlowRowsForWindow(minutes)
const catalog = getServerCatalog()
export async function listFlowTalkers(minutes = 5): Promise<FlowStatsDto> {
const settings = await getTrafficFlowSettingsRow()
const runtime = await getFlowRuntimeCounters()
const rows = await listFlowRowsForWindow(minutes)
const catalog = await getServerCatalog()
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
const protoBytes = new Map<number, number>()
@@ -405,13 +419,13 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
}
}
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
applyExporterCtxFromDb()
export async function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlowInput[]) {
await applyExporterCtxFromDb()
const serverId = resolveServerId(exporterIp)
if (serverId == null) return
queueParsedFlows(serverId, flows)
rollFlowRings()
flushPending()
await flushPending()
}
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
@@ -420,7 +434,6 @@ export function ingestParsedFlowsForServerForTests(serverId: number, flows: Pars
export function resetFlowRingsForTests() {
resetEngineForTests()
attachEngineSqlite(sqliteDatabase)
lastHeartbeat = null
wantListen = false
restartAttempts = 0
@@ -430,88 +443,50 @@ export function lastFlushUsedTransactionForTests(): boolean {
return engineLastFlushTx()
}
export function flushPendingForTests(): void {
flushPending()
export async function flushPendingForTests(): Promise<void> {
await flushPending()
}
function tableCount(name: string): number {
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
async function tableCount(name: string): Promise<number> {
const row = await dbGet<{ n: number }>(`SELECT COUNT(*) AS n FROM ${name}`)
return Number(row?.n) || 0
}
function dbFileBytes(): number {
const resolved = path.resolve(process.cwd(), env.DATABASE_PATH)
if (!existsSync(resolved)) return 0
return statSync(resolved).size
}
const PURGE_LOCK_KEY = 8_723_103
async function stopWorkerProcessAsync(): Promise<void> {
if (restartTimer) {
clearTimeout(restartTimer)
restartTimer = null
}
if (!worker) return
const current = worker
worker = null
try {
current.postMessage({ type: "stop" })
await current.terminate()
} catch {
/* ignore */
}
}
/** Удаляет сессии, minute/daily rollup и сжимает SQLite. Ключи WG и пиры JH не трогает. */
/** Удаляет сессии и minute/daily rollup. Ключи WG и пиры JH не трогает. */
export async function purgeTrafficFlowStore(): Promise<FlowPurgeDto> {
beginSqliteExclusiveOp()
try {
return await withAdvisoryLock(PURGE_LOCK_KEY, async () => {
wantListen = false
await stopWorkerProcessAsync()
resetEngineForTests()
attachEngineSqlite(sqliteDatabase)
lastHeartbeat = null
state = { bound: false, address: null }
const fileBytesBefore = dbFileBytes()
const deleted = {
buckets: tableCount("flow_buckets"),
minuteStats: tableCount("flow_minute_stats"),
minuteDims: tableCount("flow_minute_dims"),
dailyDims: tableCount("flow_daily_dims"),
buckets: await tableCount("flow_buckets"),
minuteStats: await tableCount("flow_minute_stats"),
minuteDims: await tableCount("flow_minute_dims"),
dailyDims: await tableCount("flow_daily_dims"),
}
sqliteDatabase.exec(`
DELETE FROM flow_buckets;
DELETE FROM flow_minute_stats;
DELETE FROM flow_minute_dims;
DELETE FROM flow_daily_dims;
`)
resetFlowIngestCounters()
await dbQuery(`DELETE FROM flow_buckets`)
await dbQuery(`DELETE FROM flow_minute_stats`)
await dbQuery(`DELETE FROM flow_minute_dims`)
await dbQuery(`DELETE FROM flow_daily_dims`)
await resetFlowIngestCounters()
await dropExpiredPartitions(pool)
try {
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
await startTrafficFlowListener()
} catch {
/* ignore */
}
let vacuumed = false
try {
sqliteDatabase.exec("VACUUM")
vacuumed = true
} catch {
vacuumed = false
/* ingest мог остаться выключенным */
}
return {
ok: true,
deleted,
fileBytesBefore,
fileBytesAfter: dbFileBytes(),
vacuumed,
fileBytesBefore: 0,
fileBytesAfter: 0,
vacuumed: false,
}
} finally {
try {
startTrafficFlowListener()
} catch {
/* ingest мог остаться выключенным */
}
endSqliteExclusiveOp()
}
})
}
export { peekPendingFlows }
@@ -5,6 +5,7 @@ import {
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
import { withPgOrSkip } from "../test/pg.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
import {
@@ -14,6 +15,11 @@ import {
seedRipeCacheForTests,
} from "./traffic-flow-ripe.js"
if (!(await withPgOrSkip())) {
console.log("traffic-flow-map-hops.test.ts: skip")
process.exit(0)
}
disableCatalogFetchForTests()
resetFlowCatalogForTests()
disableRipePersistForTests()
@@ -115,7 +121,7 @@ ingestParsedFlowsForServerForTests(3, [
try {
resetFlowMapHopsCacheForTests()
const def = buildFlowMapHops({ minutes: 5 })
const def = await buildFlowMapHops({ minutes: 5 })
assert.equal(def.excludeOverlayApplied, true)
assert.equal(def.excludeMeshApplied, true)
assert.equal(def.dedupApplied, true)
@@ -144,7 +150,7 @@ try {
assert.equal(wan.bytes, 3000)
resetFlowMapHopsCacheForTests()
const withAll = buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
const withAll = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
@@ -203,7 +209,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const six = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const six = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(six.totalBytes, 10_000)
const google = six.services?.find((s) => s.id === "svc:google")
assert.ok(google, "Google ≥ 5%")
@@ -233,11 +239,11 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const four = buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
assert.equal(four.totalBytes, 10_000)
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
resetFlowMapHopsCacheForTests()
const off = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
} finally {
resetFlowRingsForTests()
@@ -270,7 +276,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
const greOnly = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
} finally {
seedFlowTopologyForTests(null)
@@ -317,7 +323,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const rev = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const rev = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
assert.ok(rev.services?.some((s) => s.id === "svc:google"), "реверс Google:443 → 10.x")
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
@@ -356,7 +362,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const wan = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const wan = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google с WAN JH")
assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH")
@@ -395,7 +401,7 @@ ingestParsedFlowsForServerForTests(7, [
])
try {
resetFlowMapHopsCacheForTests()
const wanOnly = buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const wanOnly = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:google")
assert.ok(googleEdge, "Google WAN без GRE payload")
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
+12 -12
View File
@@ -113,9 +113,9 @@ function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
})
}
function userIfaceAllow(userId: string): Map<number, Set<string>> | null {
async function userIfaceAllow(userId: string): Promise<Map<number, Set<string>> | null> {
if (!userId) return null
const binds = db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
const binds = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
const allow = new Map<number, Set<string>>()
for (const b of binds) {
const set = allow.get(b.serverId) ?? new Set<string>()
@@ -182,27 +182,27 @@ function classifyMapDstLite(
return brand
}
function resolveMinSharePct(q: FlowMapHopsQuery): number {
async function resolveMinSharePct(q: FlowMapHopsQuery): Promise<number> {
if (q.minSharePct != null) return clampMapServiceMinSharePct(q.minSharePct)
try {
const row = getTrafficFlowSettingsRow() as { mapServiceMinSharePct?: number }
const row = await getTrafficFlowSettingsRow()
return clampMapServiceMinSharePct(row.mapServiceMinSharePct ?? DEFAULT_MAP_SERVICE_MIN_SHARE_PCT)
} catch {
return DEFAULT_MAP_SERVICE_MIN_SHARE_PCT
}
}
function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): FlowMapHopsDto {
async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Promise<FlowMapHopsDto> {
const windowSec = Math.max(60, q.minutes * 60)
const raw = listFlowRowsForWindow(q.minutes)
const allow = q.userId ? userIfaceAllow(q.userId) : null
const catalog = getServerCatalog()
const raw = await listFlowRowsForWindow(q.minutes)
const allow = q.userId ? await userIfaceAllow(q.userId) : null
const catalog = await getServerCatalog()
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
const wantDedup = q.dedup !== false && !ifaceFilter
const excludeMesh = q.excludeMesh !== false
const excludeOverlay = q.excludeOverlay !== false
const topo = loadFlowTopology()
const topo = await loadFlowTopology()
const matched = []
for (const r of raw) {
@@ -508,14 +508,14 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
}
/** Hop-rates для карты сети: те же фильтры, что у общего NetFlow (dedup / mesh / overlay). */
export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
const minSharePct = resolveMinSharePct(q)
export async function buildFlowMapHops(q: FlowMapHopsQuery): Promise<FlowMapHopsDto> {
const minSharePct = await resolveMinSharePct(q)
const key = hopsQueryKey(q, minSharePct)
const now = Date.now()
if (hopsCache && hopsCache.key === key && now - hopsCache.at < HOPS_CACHE_TTL_MS) {
return hopsCache.dto
}
const dto = buildFlowMapHopsUncached(q, minSharePct)
const dto = await buildFlowMapHopsUncached(q, minSharePct)
hopsCache = { key, at: now, dto }
return dto
}
+12 -15
View File
@@ -181,14 +181,14 @@ export async function applyFlowOverlay(
opts?: { publicEndpoint?: string; requestHost?: string },
): Promise<TrafficFlowOverlayResult> {
const steps: string[] = []
const keys = ensureHostKeys()
const settings = getTrafficFlowSettingsRow()
const keys = await ensureHostKeys()
const settings = await getTrafficFlowSettingsRow()
const hostPublicKey = settings.hostPublicKey || keys.publicKey
if (!hostPublicKey) {
throw Object.assign(new Error("Не удалось создать ключи хоста MM"), { statusCode: 500 })
}
const server = getEnabledServerById(String(serverIdRaw))
const server = await getEnabledServerById(String(serverIdRaw))
if (!server || !server.enabled) {
throw Object.assign(new Error("Сервер не найден или выключен"), { statusCode: 404 })
}
@@ -199,11 +199,8 @@ export async function applyFlowOverlay(
}
const peerEndpoint = `${endpointHost}:${JH_LISTEN_PORT}`
const taken = new Set(
db.select({ ip: servers.mgmtTunnelIp }).from(servers).all()
.map((r) => r.ip)
.filter(Boolean),
)
const takenRows = await db.select({ ip: servers.mgmtTunnelIp }).from(servers)
const taken = new Set(takenRows.map((r) => r.ip).filter((ip): ip is string => Boolean(ip)))
const address = server.mgmtTunnelIp || allocateOverlayAddress(settings.prefix, settings.collectorIp, server.id, taken)
const client = MikrotikClient.fromServer(server)
@@ -285,13 +282,13 @@ export async function applyFlowOverlay(
throw new Error("Не удалось прочитать public-key интерфейса wg-flow")
}
db.update(servers).set({
await db.update(servers).set({
mgmtTunnelIp: address,
updatedAt: new Date().toISOString(),
}).where(eq(servers.id, server.id)).run()
}).where(eq(servers.id, server.id))
invalidateFlowCatalogCache()
upsertHostPeer({
await upsertHostPeer({
serverId: server.id,
name: server.name || server.host,
publicKey,
@@ -300,9 +297,9 @@ export async function applyFlowOverlay(
endpoint: peerEndpoint,
})
enableTrafficFlowIngest()
startTrafficFlowListener()
refreshFlowExporterMap()
await enableTrafficFlowIngest()
await startTrafficFlowListener()
await refreshFlowExporterMap()
steps.push("Коллектор IPFIX на MM включён")
return {
@@ -314,7 +311,7 @@ export async function applyFlowOverlay(
linuxPeerBlock: linuxPeerBlock(publicKey, address, server.name || server.host, endpointHost),
trafficFlow: true,
steps,
hostFiles: listTrafficFlowHostFiles(),
hostFiles: await listTrafficFlowHostFiles(),
}
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
+62 -53
View File
@@ -1,54 +1,68 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync } from "node:fs"
import os from "node:os"
import path from "node:path"
import { dbQuery } from "../db/index.js"
import { withPgOrSkip } from "../test/pg.js"
import { ensurePartitionFor } from "../db/partitions.js"
import { pool } from "../db/index.js"
const dir = mkdtempSync(path.join(os.tmpdir(), "mm-flow-purge-"))
process.env.DATABASE_PATH = path.join(dir, "test.db")
if (!(await withPgOrSkip())) {
console.log("traffic-flow-purge.test.ts: skip")
process.exit(0)
}
const nServers = (await dbQuery<{ n: number }>(`SELECT COUNT(*)::int AS n FROM servers`)).rows[0]?.n ?? 0
if (nServers > 10) {
console.warn("traffic-flow-purge.test.ts: skip (не пустая БД)")
process.exit(0)
}
const { sqliteDatabase } = await import("../db/index.js")
const {
getFlowRuntimeCounters,
purgeTrafficFlowStore,
stopTrafficFlowListener,
} = await import("./traffic-flow-ingest.js")
function count(name: string): number {
const row = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM ${name}`).get() as { n: number }
return Number(row?.n) || 0
async function count(name: string): Promise<number> {
const row = await dbQuery<{ n: number }>(`SELECT COUNT(*)::int AS n FROM ${name}`)
return Number(row.rows[0]?.n) || 0
}
try {
sqliteDatabase.prepare(`
INSERT INTO servers (name, host) VALUES ('purge-test', '127.0.0.1')
`).run()
const serverId = Number(
(sqliteDatabase.prepare(`SELECT id FROM servers WHERE name = 'purge-test'`).get() as { id: number }).id,
)
sqliteDatabase.prepare(`
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface)
VALUES (?, '2026-01-01T00:00:00.000Z', '10.0.0.1', '8.8.8.8', 6, 50000, 443, 100, 1, 'wg-flow')
`).run(serverId)
sqliteDatabase.prepare(`
INSERT INTO flow_minute_stats (server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations)
VALUES (?, '2026-01-01T00:00:00.000Z', 100, 1, 1, 1, 1)
`).run(serverId)
sqliteDatabase.prepare(`
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
VALUES (?, '2026-01-01T00:00:00.000Z', 'country', 'RU', 100, 1)
`).run(serverId)
sqliteDatabase.prepare(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (?, '2026-01-01', 'country', 'RU', 100, 1)
`).run(serverId)
sqliteDatabase.prepare(`
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, ?)
`).run(new Date().toISOString())
sqliteDatabase.prepare(`
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
`).run()
await dbQuery(`
INSERT INTO servers (name, host) VALUES ('purge-test', '127.0.0.1')
`)
const sidRow = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'purge-test'`)
const serverId = sidRow.rows[0]!.id
const ts = new Date("2026-01-01T00:00:00.000Z")
await ensurePartitionFor(pool, "flow_buckets", "day", ts)
await ensurePartitionFor(pool, "flow_minute_stats", "day", ts)
await ensurePartitionFor(pool, "flow_minute_dims", "day", ts)
await ensurePartitionFor(pool, "flow_daily_dims", "month", ts)
await dbQuery(`
INSERT INTO flow_buckets (server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface)
VALUES ($1, '2026-01-01T00:00:00.000Z', '10.0.0.1', '8.8.8.8', 6, 50000, 443, 100, 1, 'wg-flow')
`, [serverId])
await dbQuery(`
INSERT INTO flow_minute_stats (server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations)
VALUES ($1, '2026-01-01T00:00:00.000Z', 100, 1, 1, 1, 1)
`, [serverId])
await dbQuery(`
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
VALUES ($1, '2026-01-01T00:00:00.000Z', 'country', 'RU', 100, 1)
`, [serverId])
await dbQuery(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES ($1, '2026-01-01', 'country', 'RU', 100, 1)
`, [serverId])
await dbQuery(`
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, now())
ON CONFLICT (prefix) DO NOTHING
`)
await dbQuery(`
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
`)
try {
const result = await purgeTrafficFlowStore()
stopTrafficFlowListener()
@@ -57,21 +71,16 @@ try {
assert.equal(result.deleted.minuteStats, 1)
assert.equal(result.deleted.minuteDims, 1)
assert.equal(result.deleted.dailyDims, 1)
assert.equal(count("flow_buckets"), 0)
assert.equal(count("flow_minute_stats"), 0)
assert.equal(count("flow_minute_dims"), 0)
assert.equal(count("flow_daily_dims"), 0)
assert.equal(count("flow_ip_meta"), 1)
assert.equal(count("servers"), 1)
assert.equal(getFlowRuntimeCounters().packetsReceived, 0)
assert.equal(getFlowRuntimeCounters().lastExporterIp, null)
assert.equal(await count("flow_buckets"), 0)
assert.equal(await count("flow_minute_stats"), 0)
assert.equal(await count("flow_minute_dims"), 0)
assert.equal(await count("flow_daily_dims"), 0)
assert.ok((await count("flow_ip_meta")) >= 1)
assert.ok((await count("servers")) >= 1)
assert.equal((await getFlowRuntimeCounters()).packetsReceived, 0)
assert.equal((await getFlowRuntimeCounters()).lastExporterIp, null)
} finally {
try {
sqliteDatabase.close()
} catch {
/* ignore */
}
rmSync(dir, { recursive: true, force: true })
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
}
console.log("traffic-flow-purge.test.ts: ok")
+33 -37
View File
@@ -1,4 +1,4 @@
import { sqliteDatabase } from "../db/index.js"
import { dbAll, dbQuery } from "../db/index.js"
import { ipv4ToInt, isNonPublicIp, parseCidrV4 } from "./traffic-flow-ip.js"
import { resolveRipeCountry } from "./traffic-flow-brands.js"
@@ -155,11 +155,12 @@ function loadSqlite(): void {
return
}
loaded = true
void loadPg()
}
async function loadPg(): Promise<void> {
try {
const rows = sqliteDatabase.prepare(`
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
FROM flow_ip_meta
`).all() as Array<{
const rows = await dbAll<{
prefix: string
asn: number | null
country: string
@@ -168,7 +169,10 @@ function loadSqlite(): void {
holder: string
ok: number
fetched_at: string
}>
}>(`
SELECT prefix, asn, country, lat, lng, holder, ok, fetched_at
FROM flow_ip_meta
`)
for (const r of rows) {
const fetchedAt = Date.parse(r.fetched_at)
const asn = Number(r.asn ?? 0) || 0
@@ -184,11 +188,11 @@ function loadSqlite(): void {
fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0,
})
}
const asns = sqliteDatabase.prepare(`SELECT asn, holder, fetched_at FROM flow_asn_meta`).all() as Array<{
const asns = await dbAll<{
asn: number
holder: string
fetched_at: string
}>
}>(`SELECT asn, holder, fetched_at FROM flow_asn_meta`)
for (const a of asns) {
const fetchedAt = Date.parse(a.fetched_at)
asnHolder.set(a.asn, { holder: a.holder || "", fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0 })
@@ -200,54 +204,46 @@ function loadSqlite(): void {
function persist(entry: FlowIpMeta): void {
if (!persistEnabled) return
try {
sqliteDatabase.prepare(`
void dbQuery(`
INSERT INTO flow_ip_meta (prefix, asn, country, lat, lng, holder, ok, fetched_at)
VALUES (@prefix, @asn, @country, @lat, @lng, @holder, @ok, @fetchedAt)
ON CONFLICT(prefix) DO UPDATE SET
asn=excluded.asn, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
holder=excluded.holder, ok=excluded.ok, fetched_at=excluded.fetched_at
`).run({
prefix: entry.prefix,
asn: entry.asn,
country: entry.country,
lat: entry.lat,
lng: entry.lng,
holder: entry.holder,
ok: entry.ok ? 1 : 0,
fetchedAt: new Date(entry.fetchedAt).toISOString(),
})
} catch {
/* ignore persist errors */
}
`, {
prefix: entry.prefix,
asn: entry.asn,
country: entry.country,
lat: entry.lat,
lng: entry.lng,
holder: entry.holder,
ok: entry.ok ? 1 : 0,
fetchedAt: new Date(entry.fetchedAt).toISOString(),
}).catch(() => { /* ignore persist errors */ })
}
function persistAsn(asn: number, holder: string): void {
if (!persistEnabled || !asn) return
try {
sqliteDatabase.prepare(`
void dbQuery(`
INSERT INTO flow_asn_meta (asn, holder, fetched_at)
VALUES (@asn, @holder, @fetchedAt)
ON CONFLICT(asn) DO UPDATE SET holder=excluded.holder, fetched_at=excluded.fetched_at
`).run({
asn,
holder,
fetchedAt: new Date().toISOString(),
})
} catch {
/* ignore */
}
`, {
asn,
holder,
fetchedAt: new Date().toISOString(),
}).catch(() => { /* ignore */ })
}
/** Удаляет просроченный RIPE-кэш с диска (hit 24h / negative 6h). */
export function pruneRipeSqlite(nowMs = Date.now()): void {
export async function pruneRipeSqlite(nowMs = Date.now()): Promise<void> {
if (!persistEnabled) return
try {
const hitCutoff = new Date(nowMs - HIT_TTL_MS).toISOString()
const negCutoff = new Date(nowMs - NEG_TTL_MS).toISOString()
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok != 0 AND fetched_at < ?`).run(hitCutoff)
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok = 0 AND fetched_at < ?`).run(negCutoff)
sqliteDatabase.prepare(`DELETE FROM flow_asn_meta WHERE fetched_at < ?`).run(hitCutoff)
await dbQuery(`DELETE FROM flow_ip_meta WHERE ok != 0 AND fetched_at < ?`, [hitCutoff])
await dbQuery(`DELETE FROM flow_ip_meta WHERE ok = 0 AND fetched_at < ?`, [negCutoff])
await dbQuery(`DELETE FROM flow_asn_meta WHERE fetched_at < ?`, [hitCutoff])
} catch {
/* table may not exist in isolated tests */
}
+50 -51
View File
@@ -1,10 +1,13 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { trafficFlowSettings } from "../db/schema.js"
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
import { generateWireGuardKeyPair } from "./wg-keys.js"
let settingsRowCache: ReturnType<typeof readSettingsRow> | null = null
type SettingsRow = NonNullable<Awaited<ReturnType<typeof readSettingsRow>>>
let settingsRowCache: SettingsRow | null = null
function nowIso() {
return new Date().toISOString()
@@ -14,19 +17,19 @@ export function invalidateTrafficFlowSettingsCache(): void {
settingsRowCache = null
}
function readSettingsRow() {
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
async function readSettingsRow() {
return (await db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1))[0]
}
export function getTrafficFlowSettingsRow() {
export async function getTrafficFlowSettingsRow(): Promise<SettingsRow> {
if (settingsRowCache) return settingsRowCache
const row = readSettingsRow()
let row = await readSettingsRow()
if (row) {
settingsRowCache = row
return row
}
const now = nowIso()
db.insert(trafficFlowSettings).values({
await db.insert(trafficFlowSettings).values({
id: 1,
enabled: false,
collectorIp: "10.255.254.1",
@@ -35,27 +38,23 @@ export function getTrafficFlowSettingsRow() {
prefix: "10.255.254.0/24",
createdAt: now,
updatedAt: now,
}).run()
settingsRowCache = readSettingsRow()
return settingsRowCache!
})
row = await readSettingsRow()
settingsRowCache = row!
return settingsRowCache
}
function parsePeers(raw: string): FlowHostPeer[] {
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter((p): p is FlowHostPeer =>
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
)
} catch {
return []
}
function parsePeers(raw: unknown): FlowHostPeer[] {
const arr = parseJsonArray(raw)
return arr.filter((p): p is FlowHostPeer =>
p != null && typeof p === "object" && typeof (p as FlowHostPeer).publicKey === "string",
)
}
export function toTrafficFlowSettingsDto(
export async function toTrafficFlowSettingsDto(
listener: { bound: boolean; address: string | null },
): TrafficFlowSettingsDto {
const row = getTrafficFlowSettingsRow()
): Promise<TrafficFlowSettingsDto> {
const row = await getTrafficFlowSettingsRow()
return {
enabled: row.enabled,
collectorIp: row.collectorIp,
@@ -79,9 +78,9 @@ export function toTrafficFlowSettingsDto(
}
}
export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
const row = getTrafficFlowSettingsRow()
db.update(trafficFlowSettings).set({
export async function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
const row = await getTrafficFlowSettingsRow()
await db.update(trafficFlowSettings).set({
enabled: patch.enabled ?? row.enabled,
collectorIp: patch.collectorIp ?? row.collectorIp,
flowListenPort: patch.flowListenPort ?? row.flowListenPort,
@@ -95,75 +94,75 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
? row.mapServiceMinSharePct
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
return getTrafficFlowSettingsRow()
return await getTrafficFlowSettingsRow()
}
export function ensureHostKeys(): { publicKey: string; created: boolean } {
const row = getTrafficFlowSettingsRow()
export async function ensureHostKeys(): Promise<{ publicKey: string; created: boolean }> {
const row = await getTrafficFlowSettingsRow()
if (row.hostPublicKey && row.hostPrivateKey) {
return { publicKey: row.hostPublicKey, created: false }
}
const keys = generateWireGuardKeyPair()
db.update(trafficFlowSettings).set({
await db.update(trafficFlowSettings).set({
hostPublicKey: keys.publicKey,
hostPrivateKey: keys.privateKey,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
return { publicKey: keys.publicKey, created: true }
}
export function upsertHostPeer(peer: FlowHostPeer) {
const row = getTrafficFlowSettingsRow()
export async function upsertHostPeer(peer: FlowHostPeer) {
const row = await getTrafficFlowSettingsRow()
const peers = parsePeers(row.peersJson).filter((p) => p.serverId !== peer.serverId)
peers.push(peer)
db.update(trafficFlowSettings).set({
peersJson: JSON.stringify(peers),
await db.update(trafficFlowSettings).set({
peersJson: peers,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
}
export function recordFlowPacket(exporterIp: string) {
const row = getTrafficFlowSettingsRow()
db.update(trafficFlowSettings).set({
export async function recordFlowPacket(exporterIp: string) {
const row = await getTrafficFlowSettingsRow()
await db.update(trafficFlowSettings).set({
lastDatagramAt: nowIso(),
lastExporterIp: exporterIp,
packetsReceived: row.packetsReceived + 1,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
}
export function recordFlowListenerError(message: string) {
db.update(trafficFlowSettings).set({
export async function recordFlowListenerError(message: string) {
await db.update(trafficFlowSettings).set({
lastError: message,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
}
export function enableTrafficFlowIngest() {
db.update(trafficFlowSettings).set({
export async function enableTrafficFlowIngest() {
await db.update(trafficFlowSettings).set({
enabled: true,
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
}
export function listHostPeers(): FlowHostPeer[] {
return parsePeers(getTrafficFlowSettingsRow().peersJson)
export async function listHostPeers(): Promise<FlowHostPeer[]> {
return parsePeers((await getTrafficFlowSettingsRow()).peersJson)
}
export function resetFlowIngestCounters(): void {
db.update(trafficFlowSettings).set({
export async function resetFlowIngestCounters(): Promise<void> {
await db.update(trafficFlowSettings).set({
packetsReceived: 0,
lastDatagramAt: null,
lastExporterIp: null,
lastError: "",
updatedAt: nowIso(),
}).where(eq(trafficFlowSettings.id, 1)).run()
}).where(eq(trafficFlowSettings.id, 1))
invalidateTrafficFlowSettingsCache()
}
+19 -22
View File
@@ -1,4 +1,5 @@
import { db, sqliteDatabase } from "../db/index.js"
import { db, dbAll } from "../db/index.js"
import { parseJsonArray } from "../db/json.js"
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
import { mapRosInterfaceType } from "../modules/users/iface-type.js"
import type { PlaneTopology } from "./traffic-flow-planes.js"
@@ -47,12 +48,12 @@ export function invalidateFlowCatalogCache(): void {
serverCatalogCache = null
}
export function getServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } {
export async function getServerCatalog(): Promise<{ list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> }> {
const now = Date.now()
if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) {
return serverCatalogCache
}
const rows = db.select().from(servers).all()
const rows = await db.select().from(servers)
const list: ServerCatalogEntry[] = rows.map((s) => ({
id: s.id,
name: s.name || s.host,
@@ -66,26 +67,22 @@ export function getServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<numb
return serverCatalogCache
}
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
try {
const parsed = JSON.parse(raw || "[]") as unknown
return Array.isArray(parsed) ? parsed as Array<{ iface?: string; ip?: string }> : []
} catch {
return []
}
function parseWanUplinks(raw: unknown): Array<{ iface?: string; ip?: string }> {
const arr = Array.isArray(raw) ? raw : parseJsonArray(raw)
return arr as Array<{ iface?: string; ip?: string }>
}
function ifaceKey(serverId: number, name: string): string {
return `${serverId}|${name}`
}
export function loadFlowTopology(): FlowTopology {
export async function loadFlowTopology(): Promise<FlowTopology> {
if (seeded) return seeded
const now = Date.now()
if (topologyCache && now - topologyCache.at < CATALOG_TTL_MS) return topologyCache.topo
const serverRows = db.select().from(servers).all()
const users = db.select().from(appUsers).all()
const binds = db.select().from(userInterfaceBindings).all()
const serverRows = await db.select().from(servers)
const users = await db.select().from(appUsers)
const binds = await db.select().from(userInterfaceBindings)
const loginById = new Map(users.map((u) => [u.id, u]))
const clientIfaces = new Map<number, Set<string>>()
const clientByIface = new Map<string, FlowClientBinding>()
@@ -178,21 +175,21 @@ export function enGreIfaceNames(topo: FlowTopology, serverId: number, ifaceNames
})
}
export function latestWireBps(serverId: number, ifaceNames: string[]): { bps: number; bytes: number } {
export async function latestWireBps(serverId: number, ifaceNames: string[]): Promise<{ bps: number; bytes: number }> {
if (!ifaceNames.length) return { bps: 0, bytes: 0 }
const placeholders = ifaceNames.map(() => "?").join(",")
const rows = sqliteDatabase.prepare(`
SELECT interface_name AS name, rx_bps AS rxBps, tx_bps AS txBps, rx_bytes AS rxBytes, tx_bytes AS txBytes
FROM traffic_samples
WHERE server_id = ? AND interface_name IN (${placeholders})
ORDER BY sampled_at DESC
`).all(serverId, ...ifaceNames) as Array<{
const rows = await dbAll<{
name: string
rxBps: number
txBps: number
rxBytes: number
txBytes: number
}>
}>(`
SELECT interface_name AS name, rx_bps AS "rxBps", tx_bps AS "txBps", rx_bytes AS "rxBytes", tx_bytes AS "txBytes"
FROM traffic_samples
WHERE server_id = ? AND interface_name IN (${placeholders})
ORDER BY sampled_at DESC
`, [serverId, ...ifaceNames])
const seen = new Set<string>()
let bps = 0
let bytes = 0
+11 -11
View File
@@ -65,25 +65,25 @@ function seriesFromBuilt(built: BuiltTrafficSeries) {
}
}
function serverStatus(serverId: number): "online" | "offline" {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
async function serverStatus(serverId: number): Promise<"online" | "offline"> {
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row?.enabled) return "offline"
return "online"
}
export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): UserTrafficDto[] {
export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): Promise<UserTrafficDto[]> {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = listUsers()
const sampleCache = new Map<number, ReturnType<typeof readServerSamplesInRange>>()
const users = await listUsers()
const sampleCache = new Map<number, Awaited<ReturnType<typeof readServerSamplesInRange>>>()
return users.map((user) => {
return Promise.all(users.map(async (user) => {
const parts: BuiltTrafficSeries[] = []
const interfaces: BoundIfaceTrafficDto[] = []
for (const b of user.bindings) {
let rows = sampleCache.get(b.serverId)
if (!rows) {
rows = readServerSamplesInRange(b.serverId, sinceIso)
rows = await readServerSamplesInRange(b.serverId, sinceIso)
sampleCache.set(b.serverId, rows)
}
const peerKey = b.peerPublicKey ?? ""
@@ -111,7 +111,7 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
serverSite: b.serverSite,
serverCountry: b.serverCountry,
...seriesFromBuilt(built),
status: running && serverStatus(b.serverId) === "online" ? "online" : "offline",
status: running && (await serverStatus(b.serverId)) === "online" ? "online" : "offline",
})
}
@@ -125,9 +125,9 @@ export function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number):
interfaces,
...seriesFromBuilt(merged),
}
})
}))
}
export function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): BoundIfaceTrafficDto[] {
return buildUserTrafficList(rangeStartMs, rangeEndMs).flatMap((u) => u.interfaces)
export async function buildBoundInterfaceTraffic(rangeStartMs: number, rangeEndMs: number): Promise<BoundIfaceTrafficDto[]> {
return (await buildUserTrafficList(rangeStartMs, rangeEndMs)).flatMap((u) => u.interfaces)
}
+68 -71
View File
@@ -43,11 +43,11 @@ function parsePingTimeMs(raw: string | undefined): number | null {
return Number.isFinite(n) ? n : null
}
export function getSettings() {
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
export async function getSettings() {
const row = (await db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1))[0]
if (row) return row
const now = new Date().toISOString()
db.insert(uptimeSettings).values({
await db.insert(uptimeSettings).values({
id: 1,
enabled: true,
resourcesEnabled: true,
@@ -59,14 +59,14 @@ export function getSettings() {
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
})
return (await db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1))[0]
}
function cleanup(retentionDays: number) {
async 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()
await db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff))
await db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff))
}
export function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string; "avg-rtt"?: string }>) {
@@ -100,19 +100,18 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent?
}
/** Узлы с REST-опросом для uptime: включённые в каталоге или любой Home Router (см. /servers — HR часто «выкл.» при доступе только из LAN). */
function serversForUptimeRosPoll() {
return db
async function serversForUptimeRosPoll() {
return await db
.select()
.from(servers)
.where(or(eq(servers.enabled, true), eq(servers.type, "home-router")))
.all()
}
function lastProbeSampleTimes(): Map<string, string> {
const rows = db.select({
async function lastProbeSampleTimes(): Promise<Map<string, string>> {
const rows = await db.select({
probeId: uptimeProbeSamples.probeId,
lastAt: max(uptimeProbeSamples.sampledAt),
}).from(uptimeProbeSamples).groupBy(uptimeProbeSamples.probeId).all()
}).from(uptimeProbeSamples).groupBy(uptimeProbeSamples.probeId)
const m = new Map<string, string>()
for (const r of rows) {
if (r.lastAt) m.set(r.probeId, String(r.lastAt))
@@ -135,7 +134,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
collectingResources = true
const started = Date.now()
const now = sampledAt
const settings = getSettings()
const settings = await getSettings()
const snapshot: ResourcesRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "uptime_resources",
@@ -144,7 +143,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
}
try {
const resourceTargets = serversForUptimeRosPoll()
const resourceTargets = await serversForUptimeRosPoll()
for (const s of resourceTargets) {
const name = s.name || s.host
@@ -157,7 +156,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
const freeHdd = parseRosDataSizeBytes(resource["free-hdd-space"])
const totalHdd = parseRosDataSizeBytes(resource["total-hdd-space"])
const uptimeSeconds = parseRosUptimeToSeconds(resource["uptime"])
db.insert(uptimeResourceSamples).values({
await db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "online",
@@ -169,7 +168,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
uptimeSeconds,
boardName: String(resource["board-name"] ?? ""),
rosVersion: String(resource["version"] ?? ""),
}).run()
})
const memUsedMb = totalMem > 0 ? Math.round((totalMem - freeMem) / (1024 * 1024)) : 0
const memTotalMb = totalMem > 0 ? Math.round(totalMem / (1024 * 1024)) : 0
snapshot.servers.push({
@@ -189,7 +188,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
})
} catch (err) {
console.warn(`[uptime-collector] resource serverId=${s.id} (${s.host}):`, err instanceof Error ? err.message : err)
db.insert(uptimeResourceSamples).values({
await db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "offline",
@@ -201,7 +200,7 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
uptimeSeconds: 0,
boardName: "",
rosVersion: "",
}).run()
})
snapshot.servers.push({
serverId: s.id,
name,
@@ -212,21 +211,21 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
await cleanup(Math.max(1, settings.retentionDays))
await db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
}).where(eq(uptimeSettings.id, 1))
} catch (e) {
snapshot.fatalError = e instanceof Error ? e.message : String(e)
db.update(uptimeSettings).set({
await 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()
}).where(eq(uptimeSettings.id, 1))
} finally {
collectingResources = false
}
@@ -248,9 +247,9 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
collectingPing = true
const started = Date.now()
const now = sampledAt
const settings = getSettings()
const settings = await getSettings()
const defaultProbeSec = Math.max(5, settings.probeIntervalSec ?? settings.intervalSec)
const lastByProbe = lastProbeSampleTimes()
const lastByProbe = await lastProbeSampleTimes()
const nowMs = Date.now()
const snapshot: PingRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
@@ -261,8 +260,8 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
}
try {
const rosPollServers = serversForUptimeRosPoll()
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
const rosPollServers = await serversForUptimeRosPoll()
const probes = await db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder))
for (const p of probes) {
const effectiveSec = (p.intervalSec != null && p.intervalSec > 0) ? p.intervalSec : defaultProbeSec
@@ -282,13 +281,13 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
const client = MikrotikClient.fromServer(src)
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
const parsed = parsePing(results)
db.insert(uptimeProbeSamples).values({
await db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
}).run()
})
snapshot.probes.push({
probeId: p.id,
name: p.name,
@@ -302,13 +301,13 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
status: parsed.status,
})
} catch (err) {
db.insert(uptimeProbeSamples).values({
await db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: null,
lossPct: 100,
status: "down",
}).run()
})
snapshot.probes.push({
probeId: p.id,
name: p.name,
@@ -325,21 +324,21 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
await cleanup(Math.max(1, settings.retentionDays))
await db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
}).where(eq(uptimeSettings.id, 1))
} catch (e) {
snapshot.fatalError = e instanceof Error ? e.message : String(e)
db.update(uptimeSettings).set({
await 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()
}).where(eq(uptimeSettings.id, 1))
} finally {
collectingPing = false
}
@@ -367,12 +366,12 @@ export async function collectPingForProbeIds(probeIds: string[]): Promise<{ poll
collectingPing = true
const now = new Date().toISOString()
const settings = getSettings()
const settings = await getSettings()
let polled = 0
try {
const rosPollServers = serversForUptimeRosPoll()
const rows = db.select().from(uptimeProbes).where(inArray(uptimeProbes.id, uniqueIds)).all()
const rosPollServers = await serversForUptimeRosPoll()
const rows = await db.select().from(uptimeProbes).where(inArray(uptimeProbes.id, uniqueIds))
for (const p of rows) {
const src = rosPollServers.find((s) => s.id === p.srcServerId)
@@ -381,26 +380,26 @@ export async function collectPingForProbeIds(probeIds: string[]): Promise<{ poll
const client = MikrotikClient.fromServer(src)
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
const parsed = parsePing(results)
db.insert(uptimeProbeSamples).values({
await db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
}).run()
})
} catch {
db.insert(uptimeProbeSamples).values({
await db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: null,
lossPct: 100,
status: "down",
}).run()
})
}
polled += 1
}
cleanup(Math.max(1, settings.retentionDays))
await cleanup(Math.max(1, settings.retentionDays))
return { polled }
} finally {
collectingPing = false
@@ -418,11 +417,11 @@ export function restartUptimeCollector() {}
export function stopUptimeCollector() {}
export function readUptimeSettings() {
return getSettings()
export async function readUptimeSettings() {
return await getSettings()
}
export function updateUptimeSettings(patch: {
export async function updateUptimeSettings(patch: {
/** Устаревший: если передан без per-job флагов — выставляет все три job-флага. */
enabled?: boolean
resourcesEnabled?: boolean
@@ -435,7 +434,7 @@ export function updateUptimeSettings(patch: {
speedIntervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const prev = await getSettings()
const anyJobFlag =
patch.resourcesEnabled !== undefined
|| patch.pingEnabled !== undefined
@@ -464,24 +463,24 @@ export function updateUptimeSettings(patch: {
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
return readUptimeSettings()
await db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1))
return await readUptimeSettings()
}
export function readProbeRows() {
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
export async function readProbeRows() {
return await db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder))
}
export function updateProbeShowOnDashboard(probeId: string, showOnDashboard: boolean): boolean {
export async function updateProbeShowOnDashboard(probeId: string, showOnDashboard: boolean): Promise<boolean> {
const now = new Date().toISOString()
const r = db.update(uptimeProbes)
const r = await db.update(uptimeProbes)
.set({ showOnDashboard, updatedAt: now })
.where(eq(uptimeProbes.id, probeId))
.run()
return (r.changes ?? 0) > 0
.returning({ id: uptimeProbes.id })
return r.length > 0
}
export function replaceProbes(rows: Array<{
export async function replaceProbes(rows: Array<{
id: string
srcServerId: number
srcInterface: string
@@ -493,12 +492,12 @@ export function replaceProbes(rows: Array<{
intervalSec?: number
}>) {
const now = new Date().toISOString()
const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all()
const existing = await db.select({ id: uptimeProbes.id }).from(uptimeProbes)
const nextIds = new Set(rows.map((r) => r.id))
for (const p of existing) {
if (nextIds.has(p.id)) continue
db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run()
await db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id))
}
for (let i = 0; i < rows.length; i += 1) {
@@ -516,29 +515,27 @@ export function replaceProbes(rows: Array<{
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({
const updated = await db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).returning({ id: uptimeProbes.id })
if (updated.length > 0) continue
await db.insert(uptimeProbes).values({
id: r.id,
...patch,
createdAt: now,
}).run()
})
}
}
export function readProbeSamplesSince(sinceIso: string) {
return db.select().from(uptimeProbeSamples)
export async function readProbeSamplesSince(sinceIso: string) {
return await 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)
export async function readResourceSamplesSince(sinceIso: string, serverId: number) {
return await db.select().from(uptimeResourceSamples)
.where(and(
eq(uptimeResourceSamples.serverId, serverId),
gte(uptimeResourceSamples.sampledAt, sinceIso),
))
.orderBy(asc(uptimeResourceSamples.sampledAt))
.all()
}
+20 -20
View File
@@ -90,8 +90,8 @@ function humanizeSpeedError(raw: string): string {
export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise<SpeedTestRunResult> {
const srcId = params.srcServerId
const dstId = params.dstServerId
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0]
const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0]
if (!src || !dst) throw new Error("Server not found")
const protocol = params.protocol === "udp" ? "udp" : "tcp"
@@ -154,7 +154,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams)
const probeId = String(params.probeId ?? "").trim()
const runId = String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
if (probeId) {
db.update(uptimeSpeedProbes).set({
await db.update(uptimeSpeedProbes).set({
lastRunAt: pingNow,
lastTxAvgMbps: txFinal,
lastRxAvgMbps: rxFinal,
@@ -165,9 +165,9 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams)
lastPingAt: pingNow,
lastPingError: afterBtPing.error ?? "",
updatedAt: pingNow,
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}).where(eq(uptimeSpeedProbes.id, probeId))
}
db.insert(uptimeSpeedTestRuns).values({
await db.insert(uptimeSpeedTestRuns).values({
id: runId,
probeId: probeId || null,
srcServerId: srcId,
@@ -189,7 +189,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams)
status: "done",
error: null,
createdAt: pingNow,
}).run()
})
return {
runId,
@@ -216,10 +216,10 @@ export async function runBandwidthSpeedTest(params: SpeedTestRunParams): Promise
const srcId = params.srcServerId
const dstId = params.dstServerId
if (srcId === dstId) throw new Error("Source and destination must be different")
return withBtestNodeLocks([srcId, dstId], () => executeBandwidthSpeedTestInner(params))
return await withBtestNodeLocks([srcId, dstId], () => executeBandwidthSpeedTestInner(params))
}
export function recordSpeedTestFailure(params: {
export async function recordSpeedTestFailure(params: {
probeId?: string
runId?: string
srcServerId: number
@@ -234,14 +234,14 @@ export function recordSpeedTestFailure(params: {
const probeId = String(params.probeId ?? "").trim()
const message = humanizeSpeedError(params.message)
if (probeId) {
db.update(uptimeSpeedProbes).set({
await db.update(uptimeSpeedProbes).set({
lastRunAt: new Date().toISOString(),
lastStatus: "error",
lastError: message,
updatedAt: new Date().toISOString(),
}).where(eq(uptimeSpeedProbes.id, probeId)).run()
}).where(eq(uptimeSpeedProbes.id, probeId))
}
db.insert(uptimeSpeedTestRuns).values({
await db.insert(uptimeSpeedTestRuns).values({
id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
probeId: probeId || null,
srcServerId: params.srcServerId,
@@ -263,11 +263,11 @@ export function recordSpeedTestFailure(params: {
status: "error",
error: message,
createdAt: new Date().toISOString(),
}).run()
})
}
function serverDisplayName(id: number): string {
const s = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
async function serverDisplayName(id: number): Promise<string> {
const s = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
return s ? (s.name || s.host) : `#${id}`
}
@@ -275,7 +275,7 @@ function serverDisplayName(id: number): string {
export async function runScheduledSpeedProbesOnce(): Promise<SpeedScheduledRunSnapshot> {
const sampledAt = new Date().toISOString()
const runs: SpeedRunSnapshot[] = []
const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all()
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
const enabled = rows.filter((r) => r.enabled !== false && r.srcServerId !== r.dstServerId)
const protocol = (p: (typeof enabled)[number]) => (p.protocol === "udp" ? "udp" : "tcp")
const direction = (p: (typeof enabled)[number]) =>
@@ -297,9 +297,9 @@ export async function runScheduledSpeedProbesOnce(): Promise<SpeedScheduledRunSn
runs.push({
probeId: p.id,
srcServerId: p.srcServerId,
srcServerName: serverDisplayName(p.srcServerId),
srcServerName: await serverDisplayName(p.srcServerId),
dstServerId: p.dstServerId,
dstServerName: serverDisplayName(p.dstServerId),
dstServerName: await serverDisplayName(p.dstServerId),
srcInterface: r.srcInterface ?? "",
dstInterface: r.dstInterface ?? "",
protocol: r.protocol,
@@ -314,7 +314,7 @@ export async function runScheduledSpeedProbesOnce(): Promise<SpeedScheduledRunSn
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
recordSpeedTestFailure({
await recordSpeedTestFailure({
probeId: p.id,
srcServerId: p.srcServerId,
dstServerId: p.dstServerId,
@@ -328,9 +328,9 @@ export async function runScheduledSpeedProbesOnce(): Promise<SpeedScheduledRunSn
runs.push({
probeId: p.id,
srcServerId: p.srcServerId,
srcServerName: serverDisplayName(p.srcServerId),
srcServerName: await serverDisplayName(p.srcServerId),
dstServerId: p.dstServerId,
dstServerName: serverDisplayName(p.dstServerId),
dstServerName: await serverDisplayName(p.dstServerId),
srcInterface: p.srcInterface || "",
dstInterface: p.dstInterface || "",
protocol: protocol(p),
+6 -6
View File
@@ -168,10 +168,10 @@ export async function listWireGuardInterfaces(opts?: {
if (!Number.isFinite(id)) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: WgListResult["failures"] = []
@@ -195,7 +195,7 @@ export async function listWireGuardInterfaces(opts?: {
export async function countWireGuardInterfaces(): Promise<number> {
try {
const result = await Promise.race([
listWireGuardInterfaces({ includePrivateKey: false }),
await listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
@@ -205,10 +205,10 @@ export async function countWireGuardInterfaces(): Promise<number> {
}
}
export function getEnabledServerById(serverId: string | number): ServerRow | null {
export async function getEnabledServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
}
export type CatalogWgPeer = {
@@ -227,7 +227,7 @@ export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
peers: CatalogWgPeer[]
error?: string
}> {
const row = getEnabledServerById(serverId)
const row = await getEnabledServerById(serverId)
if (!row) return { peers: [], error: "Сервер не найден" }
try {
const client = MikrotikClient.fromServer(row)