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
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:
@@ -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[] = []
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user