PSA/shared/rmm/alerts/reconciliation.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

112 lines
4.1 KiB
TypeScript

import type { NormalizedRmmAlertEvent, RmmAlertProcessingContext } from './contracts';
import { processRmmAlertEvent } from './processRmmAlertEvent';
/**
* Marker stamped into the raw payload of poller-ingested alerts. Stale-alert
* detection only trusts ids from its own ingest source: provider webhooks may
* use a different alert id space than the provider's list-alerts API (NinjaOne
* webhooks carry activity ids; its alerts API returns uids), and a false
* "stale" verdict would close a ticket whose alert is still firing.
*/
export const RECONCILIATION_INGEST_MARKER = '__alga_ingest_source';
export interface RmmActiveAlertFetcher {
/** Returns every currently-active alert in the RMM as a triggered event. */
fetchActiveAlerts(args: { tenantId: string; integrationId: string }): Promise<NormalizedRmmAlertEvent[]>;
}
const fetchers = new Map<string, RmmActiveAlertFetcher>();
export function registerRmmAlertFetcher(provider: string, fetcher: RmmActiveAlertFetcher): void {
fetchers.set(provider, fetcher);
}
export function getRmmAlertFetcher(provider: string): RmmActiveAlertFetcher | undefined {
return fetchers.get(provider);
}
export interface ReconciliationResult {
skipped: boolean;
remoteActive: number;
ingested: number;
resetsSynthesized: number;
warnings: string[];
}
/**
* One reconciliation cycle for an integration, all through the normal
* pipeline so rules, dedup, windows, and ticketing apply identically:
*
* 1. Active alerts in the RMM that webhooks missed become triggered events
* (dedup absorbs near-duplicates; still-suppressed alerts whose window
* ended re-enter processing via reprocessSuppressed).
* 2. Poller-ingested local alerts no longer active in the RMM get synthesized
* resets — catching missed reset webhooks, the main source of stale
* tickets.
*/
export async function runRmmAlertReconciliation(
ctx: RmmAlertProcessingContext,
args: { tenantId: string; integrationId: string; provider: string }
): Promise<ReconciliationResult> {
const warnings: string[] = [];
const fetcher = getRmmAlertFetcher(args.provider);
if (!fetcher) {
return { skipped: true, remoteActive: 0, ingested: 0, resetsSynthesized: 0, warnings };
}
const remote = await fetcher.fetchActiveAlerts({
tenantId: args.tenantId,
integrationId: args.integrationId,
});
let ingested = 0;
for (const event of remote) {
const stamped: NormalizedRmmAlertEvent = {
...event,
raw: { ...event.raw, [RECONCILIATION_INGEST_MARKER]: 'reconciliation' },
};
const result = await processRmmAlertEvent(ctx, stamped, { reprocessSuppressed: true });
warnings.push(...result.warnings);
if (result.outcome !== 'skipped') ingested += 1;
}
const remoteIds = new Set(remote.map((event) => event.externalAlertId));
const locals = await ctx.knex('rmm_alerts')
.where({ tenant: args.tenantId, integration_id: args.integrationId })
.whereIn('status', ['active', 'acknowledged', 'suppressed'])
.select('external_alert_id', 'metadata');
let resetsSynthesized = 0;
const now = new Date().toISOString();
for (const row of locals) {
if (remoteIds.has(row.external_alert_id)) continue;
const metadata = typeof row.metadata === 'string' ? safeParse(row.metadata) : row.metadata;
const pollerIngested =
metadata && typeof metadata === 'object' && (metadata as Record<string, unknown>)[RECONCILIATION_INGEST_MARKER];
if (!pollerIngested) continue;
const result = await processRmmAlertEvent(ctx, {
tenantId: args.tenantId,
integrationId: args.integrationId,
provider: args.provider as NormalizedRmmAlertEvent['provider'],
kind: 'reset',
externalAlertId: row.external_alert_id,
severity: 'none',
occurredAt: now,
raw: { [RECONCILIATION_INGEST_MARKER]: 'reconciliation', reason: 'no_longer_active_in_rmm' },
});
warnings.push(...result.warnings);
if (result.outcome === 'resolved') resetsSynthesized += 1;
}
return { skipped: false, remoteActive: remote.length, ingested, resetsSynthesized, warnings };
}
function safeParse(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}