- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison) - Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis - Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison) - Add veeam-analysis-state.ts and rmm-device-resolver.ts services - Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis - Add backup-status page updates and nav links for new Veeam pages - Add scripts: deactivate-cis-for-inactive-companies, workstation category updates - Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt - Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
/**
|
|
* Veeam RPO Service
|
|
* Outcome-based backup alerting: one deduped Autotask ticket per job
|
|
* that has missed its RPO, auto-resolved when backup succeeds.
|
|
*/
|
|
|
|
import postgresClient from './postgres-client';
|
|
import { AutotaskClient } from './autotask-client';
|
|
import {
|
|
RmmDeviceInfo,
|
|
resolveRmmDevicesForJobs,
|
|
isDeviceOfflineSuppressed,
|
|
hoursOffline,
|
|
} from './rmm-device-resolver';
|
|
|
|
const AT_QUEUE_ID = 29832283; // Operations Triage
|
|
const AT_ISSUE_TYPE = 38; // Backups
|
|
const AT_SUB_ISSUE = 637; // Backup: Veeam Agent for Microsoft Windows
|
|
const AT_PRIORITY_MED = 3; // Medium
|
|
const AT_PRIORITY_HIGH = 2; // High
|
|
const AT_PRIORITY_CRIT = 1; // Critical
|
|
const AT_STATUS_NEW = 1;
|
|
const AT_STATUS_DONE = 5;
|
|
|
|
// Shadow mode: log what the service *would* do without touching Autotask.
|
|
// Default true — set VEEAM_RPO_SHADOW_MODE=false in env to go live.
|
|
const SHADOW_MODE = process.env.VEEAM_RPO_SHADOW_MODE !== 'false';
|
|
|
|
export interface RpoCheckResult {
|
|
checked: number;
|
|
newTickets: number;
|
|
escalated: number;
|
|
resolved: number;
|
|
skipped: number;
|
|
offlineSuppressed: number;
|
|
errors: string[];
|
|
runAt: Date;
|
|
}
|
|
|
|
export interface RpoJobSummary {
|
|
job_instance_uid: string;
|
|
job_name: string;
|
|
org_name: string;
|
|
status: string;
|
|
schedule_type: string;
|
|
last_end_time: string | null;
|
|
hours_since_backup: number | null;
|
|
rpo_hours: number;
|
|
is_breached: boolean;
|
|
is_offline_suppressed: boolean;
|
|
failure_category: string | null;
|
|
failure_message: string | null;
|
|
rmm_hostname: string | null;
|
|
rmm_site_name: string | null;
|
|
rmm_last_seen: string | null;
|
|
open_ticket: {
|
|
at_ticket_id: number;
|
|
at_ticket_number: string;
|
|
priority_level: string;
|
|
hours_overdue: number;
|
|
opened_at: string;
|
|
} | null;
|
|
}
|
|
|
|
function getRpoThresholds(scheduleType: string): { grace: number; high: number; critical: number } {
|
|
// grace = hours after next_run before we alert (buffer for slow jobs)
|
|
// high/critical = hours after next_run for escalation
|
|
const s = (scheduleType ?? '').toLowerCase();
|
|
if (s.includes('weekly')) {
|
|
return { grace: 4, high: 48, critical: 7 * 24 };
|
|
}
|
|
if (s.includes('continuous') || s.includes('real')) {
|
|
return { grace: 1, high: 4, critical: 12 };
|
|
}
|
|
// Daily (default) — alert 4h after missed window, escalate at 48h / 7 days
|
|
return { grace: 4, high: 48, critical: 7 * 24 };
|
|
}
|
|
|
|
function categorizeFailure(failureMessage: string | null): string {
|
|
if (!failureMessage) return 'No recent successful backup';
|
|
const msg = failureMessage.toLowerCase();
|
|
if (msg.includes('license') && (msg.includes('expired') || msg.includes('grace period'))) {
|
|
return 'License Expired — renew via VSPC';
|
|
}
|
|
if (msg.includes('vcg01') || msg.includes('cloud gateway') || msg.includes('cloud connect')) {
|
|
return 'Cloud Gateway Unreachable — check vcg01.wulfconsulting.com';
|
|
}
|
|
if (msg.includes('repository') && (msg.includes('inaccessible') || msg.includes('not accessible'))) {
|
|
return 'Backup Repository Inaccessible';
|
|
}
|
|
if (msg.includes('maintenance')) {
|
|
return 'Service Provider Maintenance';
|
|
}
|
|
if (msg.includes('ssl') || msg.includes('resolve host') || msg.includes('connection')) {
|
|
return 'Network/Connectivity Error';
|
|
}
|
|
if (msg.includes('timeout')) {
|
|
return 'Backup Job Timeout';
|
|
}
|
|
return failureMessage.trim().substring(0, 200);
|
|
}
|
|
|
|
function buildTicketTitle(jobName: string, orgName: string, hoursOverdue: number): string {
|
|
const h = Math.round(hoursOverdue);
|
|
const display = h >= 48 ? `${Math.round(h / 24)}d` : `${h}h`;
|
|
return `[Veeam RPO] ${jobName} @ ${orgName} — ${display} since last backup`;
|
|
}
|
|
|
|
function buildTicketDescription(
|
|
jobName: string,
|
|
orgName: string,
|
|
scheduleType: string,
|
|
lastEndTime: string | null,
|
|
hoursOverdue: number,
|
|
failureCategory: string,
|
|
failureMessage: string | null,
|
|
restorePoints: number | null,
|
|
): string {
|
|
const lastBackup = lastEndTime
|
|
? new Date(lastEndTime).toLocaleString('en-US', { timeZone: 'America/New_York' }) + ' ET'
|
|
: 'Never';
|
|
const lines = [
|
|
`Job: ${jobName}`,
|
|
`Organization: ${orgName}`,
|
|
`Schedule: ${scheduleType ?? 'Unknown'}`,
|
|
`Last Successful Backup: ${lastBackup}`,
|
|
`Hours Since Backup: ${Math.round(hoursOverdue)}h`,
|
|
`Restore Points Available: ${restorePoints ?? 'Unknown'}`,
|
|
``,
|
|
`Failure Reason: ${failureCategory}`,
|
|
];
|
|
if (failureMessage && failureCategory !== failureMessage.trim().substring(0, 200)) {
|
|
lines.push(``, `Raw Error: ${failureMessage.trim().substring(0, 500)}`);
|
|
}
|
|
lines.push(``, `Generated by Pulse RPO Monitor — ${new Date().toISOString()}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function getAutotaskClient(): AutotaskClient {
|
|
return new AutotaskClient({
|
|
apiUrl: process.env.AUTOTASK_API_URL || '',
|
|
username: process.env.AUTOTASK_USERNAME || '',
|
|
password: process.env.AUTOTASK_SECRET || '',
|
|
apiIntegrationCode: process.env.AUTOTASK_API_INTEGRATION_CODE || '',
|
|
});
|
|
}
|
|
|
|
export class VeeamRpoService {
|
|
async runCheck(): Promise<RpoCheckResult> {
|
|
const result: RpoCheckResult = {
|
|
checked: 0,
|
|
newTickets: 0,
|
|
escalated: 0,
|
|
resolved: 0,
|
|
skipped: 0,
|
|
offlineSuppressed: 0,
|
|
errors: [],
|
|
runAt: new Date(),
|
|
};
|
|
|
|
const client = SHADOW_MODE ? null : getAutotaskClient();
|
|
|
|
// Fetch all enabled workstation jobs with org info
|
|
const jobsRes = await postgresClient.query(`
|
|
SELECT
|
|
j.instance_uid,
|
|
j.name as job_name,
|
|
j.status,
|
|
j.schedule_type,
|
|
j.last_end_time,
|
|
j.next_run,
|
|
j.restore_points,
|
|
j.failure_message,
|
|
j.is_enabled,
|
|
j.operation_mode,
|
|
o.name as org_name,
|
|
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup
|
|
FROM veeam_backup_agent_jobs j
|
|
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
|
WHERE j.operation_mode = 'Workstation'
|
|
AND j.is_enabled = true
|
|
ORDER BY hours_since_backup DESC NULLS LAST
|
|
`);
|
|
|
|
const jobs = jobsRes.rows;
|
|
result.checked = jobs.length;
|
|
|
|
// Fetch all currently open tracking tickets (shadow or live table)
|
|
const trackingTable = SHADOW_MODE ? 'veeam_rpo_shadow_tickets' : 'veeam_rpo_tickets';
|
|
const openTicketsRes = await postgresClient.query(
|
|
`SELECT * FROM ${trackingTable} WHERE resolved_at IS NULL`
|
|
);
|
|
const openByJobUid: Record<string, any> = {};
|
|
for (const row of openTicketsRes.rows) {
|
|
openByJobUid[row.job_instance_uid] = row;
|
|
}
|
|
|
|
// Bulk-resolve RMM devices for all jobs in a single query
|
|
const rmmMap = await resolveRmmDevicesForJobs(jobs.map((j: any) => j.instance_uid));
|
|
|
|
for (const job of jobs) {
|
|
try {
|
|
const rmmDevice = rmmMap.get(job.instance_uid) ?? null;
|
|
await this.processJob(job, openByJobUid, rmmDevice, client as AutotaskClient, result);
|
|
} catch (err: any) {
|
|
result.errors.push(`${job.job_name}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Update last_checked_at for all open tracking tickets
|
|
await postgresClient.query(
|
|
`UPDATE ${trackingTable} SET last_checked_at = NOW() WHERE resolved_at IS NULL`
|
|
);
|
|
|
|
return result;
|
|
}
|
|
|
|
private async processJob(
|
|
job: any,
|
|
openByJobUid: Record<string, any>,
|
|
rmmDevice: RmmDeviceInfo | null,
|
|
client: AutotaskClient,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
const thresholds = getRpoThresholds(job.schedule_type);
|
|
const openTicket = openByJobUid[job.instance_uid] ?? null;
|
|
|
|
// Skip jobs that are currently running — they haven't failed yet
|
|
if (job.status === 'Running') {
|
|
result.skipped++;
|
|
return;
|
|
}
|
|
|
|
const hoursAgo: number | null = job.hours_since_backup !== null ? parseFloat(job.hours_since_backup) : null;
|
|
const intervalHours = (job.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
|
|
: (job.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
|
|
: 24;
|
|
|
|
// Suppress new tickets and escalations for Desktop/Laptop devices that have been
|
|
// offline longer than one backup interval — the machine was offline before the backup
|
|
// was due, so a missed backup is expected. Existing open tickets are left untouched,
|
|
// but we backfill rmm_hostname so the comparison page can match them correctly.
|
|
if (rmmDevice && isDeviceOfflineSuppressed(rmmDevice, intervalHours)) {
|
|
const openTicket = openByJobUid[job.instance_uid] ?? null;
|
|
if (openTicket && !openTicket.rmm_hostname) {
|
|
const trackingTable = SHADOW_MODE ? 'veeam_rpo_shadow_tickets' : 'veeam_rpo_tickets';
|
|
await postgresClient.query(
|
|
`UPDATE ${trackingTable} SET rmm_hostname = $1, updated_at = NOW() WHERE job_instance_uid = $2`,
|
|
[rmmDevice.hostname, job.instance_uid]
|
|
);
|
|
}
|
|
await this.logOfflineSuppression(job, rmmDevice, intervalHours);
|
|
result.offlineSuppressed++;
|
|
return;
|
|
}
|
|
|
|
// Breach rules:
|
|
// - Failed/Warning: always breached — a failed backup is a failed backup regardless of recency
|
|
// - None (never run): always breached
|
|
// - Success: only breach if last success is older than (interval + grace) — daily = 28h
|
|
// Use 3x for the >30d cap logic only; the alert threshold is interval+grace
|
|
const rpoWindowHours = intervalHours + thresholds.grace;
|
|
const isBreached = job.status === 'Failed' || job.status === 'Warning'
|
|
|| hoursAgo === null
|
|
|| hoursAgo > rpoWindowHours;
|
|
|
|
if (!isBreached) {
|
|
if (openTicket) {
|
|
if (SHADOW_MODE) {
|
|
await this.shadowResolve(openTicket, job, rmmDevice, result);
|
|
} else {
|
|
await this.resolveTicket(openTicket, client!);
|
|
result.resolved++;
|
|
}
|
|
} else {
|
|
result.skipped++;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// hoursOverdue = hours past the RPO window
|
|
const hoursOverdue = hoursAgo !== null ? hoursAgo - rpoWindowHours : thresholds.grace;
|
|
const failureCategory = categorizeFailure(job.failure_message);
|
|
const targetPriority = hoursOverdue >= thresholds.critical ? 'critical'
|
|
: hoursOverdue >= thresholds.high ? 'high'
|
|
: 'medium';
|
|
|
|
// Don't create new tickets for jobs broken longer than 30 days on first encounter.
|
|
// These are likely abandoned machines — show as breached in UI but don't flood AT.
|
|
const MAX_NEW_TICKET_AGE_HOURS = 720; // 30 days
|
|
const tooOldForNewTicket = !openTicket && (hoursAgo ?? 0) > MAX_NEW_TICKET_AGE_HOURS;
|
|
|
|
if (!openTicket) {
|
|
if (tooOldForNewTicket) {
|
|
if (SHADOW_MODE) await this.writeShadowLog(job, rmmDevice, 'would_skip_too_old', null, null, null);
|
|
result.skipped++;
|
|
return;
|
|
}
|
|
if (SHADOW_MODE) {
|
|
await this.shadowCreate(job, rmmDevice, hoursOverdue, failureCategory, targetPriority, result);
|
|
} else {
|
|
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client!, result);
|
|
}
|
|
} else {
|
|
if (openTicket.priority_level !== targetPriority && this.isPriorityHigher(targetPriority, openTicket.priority_level)) {
|
|
if (SHADOW_MODE) {
|
|
await this.shadowEscalate(openTicket, job, rmmDevice, hoursOverdue, failureCategory, targetPriority, result);
|
|
} else {
|
|
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client!, result);
|
|
}
|
|
} else {
|
|
result.skipped++;
|
|
}
|
|
}
|
|
}
|
|
|
|
private isPriorityHigher(a: string, b: string): boolean {
|
|
const rank: Record<string, number> = { medium: 1, high: 2, critical: 3 };
|
|
return (rank[a] ?? 0) > (rank[b] ?? 0);
|
|
}
|
|
|
|
// ─── Shadow mode methods ───────────────────────────────────────────────────
|
|
|
|
private async writeShadowLog(
|
|
job: any,
|
|
rmmDevice: RmmDeviceInfo | null,
|
|
action: string,
|
|
priorityLevel: string | null,
|
|
hoursOverdue: number | null,
|
|
failureCategory: string | null,
|
|
): Promise<void> {
|
|
await postgresClient.query(`
|
|
INSERT INTO veeam_rpo_shadow_log
|
|
(job_instance_uid, job_name, org_name, rmm_hostname, rmm_site_name,
|
|
action, priority_level, hours_overdue, failure_category, checked_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())
|
|
`, [
|
|
job.instance_uid, job.job_name, job.org_name,
|
|
rmmDevice?.hostname ?? null, rmmDevice?.site_name ?? null,
|
|
action, priorityLevel,
|
|
hoursOverdue !== null ? Math.round(hoursOverdue) : null,
|
|
failureCategory,
|
|
]);
|
|
}
|
|
|
|
private async shadowCreate(
|
|
job: any,
|
|
rmmDevice: RmmDeviceInfo | null,
|
|
hoursOverdue: number,
|
|
failureCategory: string,
|
|
priorityLevel: string,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
await postgresClient.query(`
|
|
INSERT INTO veeam_rpo_shadow_tickets
|
|
(job_instance_uid, job_name, org_name, rmm_hostname, priority_level,
|
|
hours_overdue, failure_category, opened_at, last_checked_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW(),NOW())
|
|
ON CONFLICT (job_instance_uid) DO UPDATE SET
|
|
priority_level = EXCLUDED.priority_level,
|
|
hours_overdue = EXCLUDED.hours_overdue,
|
|
failure_category = EXCLUDED.failure_category,
|
|
rmm_hostname = COALESCE(EXCLUDED.rmm_hostname, veeam_rpo_shadow_tickets.rmm_hostname),
|
|
resolved_at = NULL,
|
|
opened_at = NOW(),
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
`, [
|
|
job.instance_uid, job.job_name, job.org_name,
|
|
rmmDevice?.hostname ?? null, priorityLevel,
|
|
Math.round(hoursOverdue), failureCategory,
|
|
]);
|
|
await this.writeShadowLog(job, rmmDevice, 'would_create', priorityLevel, hoursOverdue, failureCategory);
|
|
result.newTickets++;
|
|
console.log(`[RPO-SHADOW] Would create ticket: ${job.job_name} @ ${job.org_name} (${Math.round(hoursOverdue)}h overdue, ${priorityLevel})`);
|
|
}
|
|
|
|
private async shadowEscalate(
|
|
openTicket: any,
|
|
job: any,
|
|
rmmDevice: RmmDeviceInfo | null,
|
|
hoursOverdue: number,
|
|
failureCategory: string,
|
|
targetPriority: string,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
await postgresClient.query(`
|
|
UPDATE veeam_rpo_shadow_tickets SET
|
|
priority_level = $1,
|
|
hours_overdue = $2,
|
|
failure_category = $3,
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE job_instance_uid = $4
|
|
`, [targetPriority, Math.round(hoursOverdue), failureCategory, job.instance_uid]);
|
|
await this.writeShadowLog(job, rmmDevice, 'would_escalate', targetPriority, hoursOverdue, failureCategory);
|
|
result.escalated++;
|
|
console.log(`[RPO-SHADOW] Would escalate to ${targetPriority}: ${job.job_name} (${Math.round(hoursOverdue)}h overdue)`);
|
|
}
|
|
|
|
private async shadowResolve(
|
|
openTicket: any,
|
|
job: any,
|
|
rmmDevice: RmmDeviceInfo | null,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
await postgresClient.query(`
|
|
UPDATE veeam_rpo_shadow_tickets SET
|
|
resolved_at = NOW(),
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE job_instance_uid = $1
|
|
`, [openTicket.job_instance_uid]);
|
|
await this.writeShadowLog(job, rmmDevice, 'would_resolve', null, null, null);
|
|
result.resolved++;
|
|
console.log(`[RPO-SHADOW] Would resolve: ${job.job_name} — backup succeeded`);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
|
|
private async logOfflineSuppression(
|
|
job: any,
|
|
rmmDevice: RmmDeviceInfo,
|
|
intervalHours: number,
|
|
): Promise<void> {
|
|
const hrs = hoursOffline(rmmDevice) ?? 0;
|
|
await postgresClient.query(`
|
|
INSERT INTO veeam_rpo_offline_log
|
|
(job_instance_uid, job_name, org_name, rmm_hostname, rmm_site_name,
|
|
device_type_category, rmm_last_seen, hours_offline, backup_interval_hours, checked_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
|
`, [
|
|
job.instance_uid,
|
|
job.job_name,
|
|
job.org_name,
|
|
rmmDevice.hostname,
|
|
rmmDevice.site_name,
|
|
rmmDevice.device_type_category,
|
|
rmmDevice.last_seen,
|
|
Math.round(hrs * 100) / 100,
|
|
intervalHours,
|
|
]);
|
|
console.log(`[RPO] Suppressed (offline ${Math.round(hrs)}h): ${job.job_name} @ ${rmmDevice.hostname}`);
|
|
}
|
|
|
|
private async createTicket(
|
|
job: any,
|
|
hoursOverdue: number,
|
|
failureCategory: string,
|
|
priorityLevel: string,
|
|
client: AutotaskClient,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
const atPriority = priorityLevel === 'critical' ? AT_PRIORITY_CRIT
|
|
: priorityLevel === 'high' ? AT_PRIORITY_HIGH
|
|
: AT_PRIORITY_MED;
|
|
|
|
const title = buildTicketTitle(job.job_name, job.org_name, hoursOverdue);
|
|
const description = buildTicketDescription(
|
|
job.job_name, job.org_name, job.schedule_type,
|
|
job.last_end_time, hoursOverdue, failureCategory,
|
|
job.failure_message, job.restore_points,
|
|
);
|
|
|
|
// Look up Autotask company ID from org name mapping
|
|
const companyRes = await postgresClient.query(`
|
|
SELECT company_id AS id FROM veeam_organizations
|
|
WHERE name = $1
|
|
LIMIT 1
|
|
`, [job.org_name]);
|
|
|
|
const companyId = companyRes.rows[0]?.id ?? null;
|
|
|
|
const ticketPayload: Record<string, any> = {
|
|
title,
|
|
description,
|
|
status: AT_STATUS_NEW,
|
|
queueID: AT_QUEUE_ID,
|
|
issueType: AT_ISSUE_TYPE,
|
|
priority: atPriority,
|
|
};
|
|
if (companyId) ticketPayload.companyID = companyId;
|
|
|
|
const ticket = await client.createTicket(ticketPayload);
|
|
|
|
await postgresClient.query(`
|
|
INSERT INTO veeam_rpo_tickets
|
|
(job_instance_uid, job_name, org_name, at_ticket_id, at_ticket_number,
|
|
priority_level, hours_overdue, failure_category, opened_at, last_checked_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
|
ON CONFLICT (job_instance_uid) DO UPDATE SET
|
|
at_ticket_id = EXCLUDED.at_ticket_id,
|
|
at_ticket_number = EXCLUDED.at_ticket_number,
|
|
priority_level = EXCLUDED.priority_level,
|
|
hours_overdue = EXCLUDED.hours_overdue,
|
|
failure_category = EXCLUDED.failure_category,
|
|
resolved_at = NULL,
|
|
opened_at = NOW(),
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
`, [
|
|
job.instance_uid,
|
|
job.job_name,
|
|
job.org_name,
|
|
ticket.id,
|
|
(ticket as any).ticketNumber ?? null,
|
|
priorityLevel,
|
|
Math.round(hoursOverdue),
|
|
failureCategory,
|
|
]);
|
|
|
|
result.newTickets++;
|
|
console.log(`[RPO] Created ticket ${(ticket as any).ticketNumber} for ${job.job_name} (${Math.round(hoursOverdue)}h overdue)`);
|
|
}
|
|
|
|
private async escalateTicket(
|
|
openTicket: any,
|
|
job: any,
|
|
hoursOverdue: number,
|
|
failureCategory: string,
|
|
targetPriority: string,
|
|
client: AutotaskClient,
|
|
result: RpoCheckResult,
|
|
): Promise<void> {
|
|
const atPriority = targetPriority === 'critical' ? AT_PRIORITY_CRIT
|
|
: targetPriority === 'high' ? AT_PRIORITY_HIGH
|
|
: AT_PRIORITY_MED;
|
|
|
|
const note = `RPO Escalation: ${Math.round(hoursOverdue)}h since last successful backup (escalated to ${targetPriority}).\nFailure reason: ${failureCategory}`;
|
|
|
|
await client.updateTicket(openTicket.at_ticket_id, { priority: atPriority });
|
|
|
|
// Add a note to the ticket
|
|
try {
|
|
await (client as any).createEntity('TicketNotes', {
|
|
ticketID: openTicket.at_ticket_id,
|
|
title: `RPO Escalation — ${targetPriority.toUpperCase()}`,
|
|
description: note,
|
|
noteType: 1,
|
|
publish: 1,
|
|
});
|
|
} catch {
|
|
// Note creation failure is non-fatal
|
|
}
|
|
|
|
await postgresClient.query(`
|
|
UPDATE veeam_rpo_tickets SET
|
|
priority_level = $1,
|
|
hours_overdue = $2,
|
|
failure_category = $3,
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $4
|
|
`, [targetPriority, Math.round(hoursOverdue), failureCategory, openTicket.id]);
|
|
|
|
result.escalated++;
|
|
console.log(`[RPO] Escalated ticket ${openTicket.at_ticket_number} to ${targetPriority} (${Math.round(hoursOverdue)}h overdue)`);
|
|
}
|
|
|
|
private async resolveTicket(openTicket: any, client: AutotaskClient): Promise<void> {
|
|
await client.updateTicket(openTicket.at_ticket_id, { status: AT_STATUS_DONE });
|
|
|
|
await postgresClient.query(`
|
|
UPDATE veeam_rpo_tickets SET
|
|
resolved_at = NOW(),
|
|
last_checked_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, [openTicket.id]);
|
|
|
|
console.log(`[RPO] Resolved ticket ${openTicket.at_ticket_number} — backup succeeded`);
|
|
}
|
|
|
|
async getStatus(): Promise<{ shadowMode: boolean; summary: Record<string, number>; jobs: RpoJobSummary[] }> {
|
|
const jobsRes = await postgresClient.query(`
|
|
SELECT
|
|
j.instance_uid,
|
|
j.name as job_name,
|
|
j.status,
|
|
j.schedule_type,
|
|
j.last_end_time,
|
|
j.next_run,
|
|
j.restore_points,
|
|
j.failure_message,
|
|
o.name as org_name,
|
|
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup,
|
|
EXTRACT(EPOCH FROM (j.next_run - NOW())) / 3600.0 as hours_until_next_run,
|
|
rt.at_ticket_id,
|
|
rt.at_ticket_number,
|
|
rt.priority_level,
|
|
rt.hours_overdue,
|
|
rt.opened_at
|
|
FROM veeam_backup_agent_jobs j
|
|
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
|
|
LEFT JOIN veeam_rpo_tickets rt
|
|
ON rt.job_instance_uid = j.instance_uid AND rt.resolved_at IS NULL
|
|
WHERE j.operation_mode = 'Workstation'
|
|
AND j.is_enabled = true
|
|
ORDER BY hours_since_backup DESC NULLS LAST
|
|
`);
|
|
|
|
const rows = jobsRes.rows;
|
|
const rmmMap = await resolveRmmDevicesForJobs(rows.map((r: any) => r.instance_uid));
|
|
|
|
const jobs: RpoJobSummary[] = rows.map((row: any) => {
|
|
const thresholds = getRpoThresholds(row.schedule_type);
|
|
const hoursAgo: number | null = row.hours_since_backup !== null ? parseFloat(row.hours_since_backup) : null;
|
|
const intervalHours = (row.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
|
|
: (row.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
|
|
: 24;
|
|
const rpoWindowHours = intervalHours + thresholds.grace;
|
|
const isBreached = row.status !== 'Running'
|
|
&& (row.status === 'Failed' || row.status === 'Warning'
|
|
|| hoursAgo === null
|
|
|| hoursAgo > rpoWindowHours);
|
|
|
|
const rmmDevice = rmmMap.get(row.instance_uid) ?? null;
|
|
const isOfflineSuppressed = isBreached
|
|
&& rmmDevice !== null
|
|
&& isDeviceOfflineSuppressed(rmmDevice, intervalHours);
|
|
|
|
return {
|
|
job_instance_uid: row.instance_uid,
|
|
job_name: row.job_name,
|
|
org_name: row.org_name,
|
|
status: row.status,
|
|
schedule_type: row.schedule_type,
|
|
last_end_time: row.last_end_time,
|
|
hours_since_backup: hoursAgo !== null ? Math.round(hoursAgo * 10) / 10 : null,
|
|
rpo_hours: thresholds.grace,
|
|
is_breached: isBreached,
|
|
is_offline_suppressed: isOfflineSuppressed,
|
|
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
|
|
failure_message: row.failure_message,
|
|
rmm_hostname: rmmDevice?.hostname ?? null,
|
|
rmm_site_name: rmmDevice?.site_name ?? null,
|
|
rmm_last_seen: rmmDevice?.last_seen?.toISOString() ?? null,
|
|
open_ticket: row.at_ticket_id ? {
|
|
at_ticket_id: row.at_ticket_id,
|
|
at_ticket_number: row.at_ticket_number,
|
|
priority_level: row.priority_level,
|
|
hours_overdue: row.hours_overdue,
|
|
opened_at: row.opened_at,
|
|
} : null,
|
|
};
|
|
});
|
|
|
|
const breached = jobs.filter(j => j.is_breached).length;
|
|
const offlineSuppressed = jobs.filter(j => j.is_offline_suppressed).length;
|
|
const withTicket = jobs.filter(j => j.open_ticket !== null).length;
|
|
const healthy = jobs.filter(j => !j.is_breached).length;
|
|
const critical = jobs.filter(j => j.open_ticket?.priority_level === 'critical').length;
|
|
const high = jobs.filter(j => j.open_ticket?.priority_level === 'high').length;
|
|
|
|
return {
|
|
shadowMode: SHADOW_MODE,
|
|
summary: {
|
|
total: jobs.length,
|
|
healthy,
|
|
breached,
|
|
offlineSuppressed,
|
|
withOpenTicket: withTicket,
|
|
critical,
|
|
high,
|
|
},
|
|
jobs,
|
|
};
|
|
}
|
|
}
|
|
|
|
let _instance: VeeamRpoService | null = null;
|
|
export function getVeeamRpoService(): VeeamRpoService {
|
|
if (!_instance) _instance = new VeeamRpoService();
|
|
return _instance;
|
|
}
|