feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- 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
This commit is contained in:
parent
07067bef19
commit
ea3471d38d
36 changed files with 5604 additions and 217 deletions
|
|
@ -103,7 +103,7 @@ export class EntitySyncService {
|
|||
|
||||
// For incremental sync, filter by last sync time
|
||||
// Note: Companies and Resources don't support date-based filtering in Autotask API
|
||||
const supportsIncremental = entity !== EntityType.COMPANIES && entity !== EntityType.RESOURCES;
|
||||
const supportsIncremental = entity !== EntityType.COMPANIES && entity !== EntityType.RESOURCES && entity !== EntityType.COMPANY_TEAMS;
|
||||
|
||||
if (isIncremental && supportsIncremental) {
|
||||
try {
|
||||
|
|
@ -415,6 +415,41 @@ export class EntitySyncService {
|
|||
}
|
||||
}
|
||||
|
||||
// After contacts sync, backfill primary_contact_id and billing_contact_id on companies
|
||||
if (entity === EntityType.CONTACTS) {
|
||||
try {
|
||||
const primaryResult = await postgresClient.query(
|
||||
`UPDATE companies c
|
||||
SET primary_contact_id = (
|
||||
SELECT id FROM contacts
|
||||
WHERE company_id = c.id AND primary_contact = true AND is_deleted = false
|
||||
ORDER BY id LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM contacts
|
||||
WHERE company_id = c.id AND primary_contact = true AND is_deleted = false
|
||||
)`
|
||||
);
|
||||
entityLogger.info('Backfilled companies.primary_contact_id', { updatedCount: (primaryResult as any).rowCount ?? 0 });
|
||||
|
||||
const billingResult = await postgresClient.query(
|
||||
`UPDATE companies c
|
||||
SET billing_contact_id = (
|
||||
SELECT id FROM contacts
|
||||
WHERE company_id = c.id AND billing_contact = true AND is_deleted = false
|
||||
ORDER BY id LIMIT 1
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM contacts
|
||||
WHERE company_id = c.id AND billing_contact = true AND is_deleted = false
|
||||
)`
|
||||
);
|
||||
entityLogger.info('Backfilled companies.billing_contact_id', { updatedCount: (billingResult as any).rowCount ?? 0 });
|
||||
} catch (err) {
|
||||
entityLogger.warn('Contact FK backfill on companies failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria.
|
||||
|
|
@ -817,6 +852,13 @@ export class EntitySyncService {
|
|||
return await this.syncEntity(EntityType.CONTACTS, isIncremental);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Company Teams (TAMs, CSMs, co-managed resources assigned to a company)
|
||||
*/
|
||||
async syncCompanyTeams(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||
return await this.syncEntity(EntityType.COMPANY_TEAMS, isIncremental);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Contracts
|
||||
*/
|
||||
|
|
|
|||
90
lib/services/rmm-device-resolver.ts
Normal file
90
lib/services/rmm-device-resolver.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* RMM Device Resolver
|
||||
* Matches Veeam backup agent jobs to their Datto RMM device via:
|
||||
* veeam_organizations.company_id → datto_rmm_sites.autotask_company_id (org match)
|
||||
* veeam_backup_agents.name ≈ datto_rmm_devices.hostname (hostname match)
|
||||
*
|
||||
* Scoped to Desktop/Laptop device categories only — server jobs are handled separately.
|
||||
* Reusable by any service that needs to correlate Veeam jobs with RMM device state.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
|
||||
export interface RmmDeviceInfo {
|
||||
hostname: string;
|
||||
site_name: string;
|
||||
device_type_category: string;
|
||||
last_seen: Date | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-resolve Veeam backup agent jobs to their matching Datto RMM devices.
|
||||
* Returns a Map keyed by job instance_uid. Jobs with no RMM match are absent.
|
||||
*/
|
||||
export async function resolveRmmDevicesForJobs(
|
||||
jobInstanceUids: string[]
|
||||
): Promise<Map<string, RmmDeviceInfo>> {
|
||||
if (jobInstanceUids.length === 0) return new Map();
|
||||
|
||||
// datto_rmm_sites.autotask_company_id is not reliably populated from the API,
|
||||
// so join via companies.company_name = datto_rmm_sites.name instead.
|
||||
// Datto sites are often named "Company - Location" while company_name is "Company",
|
||||
// so match exact OR site name starts with "company - ".
|
||||
// DISTINCT ON (job) prevents fanout when a company has multiple matching sites,
|
||||
// preferring the device with the most recent last_seen.
|
||||
const res = await postgresClient.query(`
|
||||
SELECT DISTINCT ON (j.instance_uid)
|
||||
j.instance_uid AS job_instance_uid,
|
||||
rmm.hostname,
|
||||
rs.name AS site_name,
|
||||
rmm.device_type_category,
|
||||
rmm.last_seen,
|
||||
rmm.online
|
||||
FROM veeam_backup_agent_jobs j
|
||||
JOIN veeam_backup_agents ba ON ba.instance_uid = j.backup_agent_uid
|
||||
JOIN veeam_organizations vo ON vo.instance_uid = j.organization_uid
|
||||
JOIN companies c ON c.id = vo.company_id
|
||||
JOIN datto_rmm_sites rs ON LOWER(rs.name) = LOWER(c.company_name)
|
||||
OR LOWER(rs.name) LIKE LOWER(c.company_name) || ' - %'
|
||||
JOIN datto_rmm_devices rmm
|
||||
ON rmm.site_id = rs.id
|
||||
AND LOWER(rmm.hostname) = LOWER(ba.name)
|
||||
AND rmm.device_type_category IN ('Desktop', 'Laptop')
|
||||
AND rmm.deleted = false
|
||||
WHERE j.instance_uid = ANY($1)
|
||||
ORDER BY j.instance_uid, rmm.last_seen DESC NULLS LAST
|
||||
`, [jobInstanceUids]);
|
||||
|
||||
const map = new Map<string, RmmDeviceInfo>();
|
||||
for (const row of res.rows) {
|
||||
map.set(row.job_instance_uid, {
|
||||
hostname: row.hostname,
|
||||
site_name: row.site_name,
|
||||
device_type_category: row.device_type_category,
|
||||
last_seen: row.last_seen ? new Date(row.last_seen) : null,
|
||||
online: row.online,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the device should suppress RPO alerting.
|
||||
* A device is considered suppressed if it has been offline longer than one full
|
||||
* backup interval — meaning it was already offline when the backup was due to run.
|
||||
* No last_seen → not suppressed (device exists in RMM but has never reported; alert normally).
|
||||
*/
|
||||
export function isDeviceOfflineSuppressed(info: RmmDeviceInfo, intervalHours: number): boolean {
|
||||
if (!info.last_seen) return false;
|
||||
const hoursOffline = (Date.now() - info.last_seen.getTime()) / 3_600_000;
|
||||
return hoursOffline > intervalHours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns hours since the device was last seen by RMM, or null if never seen.
|
||||
*/
|
||||
export function hoursOffline(info: RmmDeviceInfo): number | null {
|
||||
if (!info.last_seen) return null;
|
||||
return (Date.now() - info.last_seen.getTime()) / 3_600_000;
|
||||
}
|
||||
9
lib/services/veeam-analysis-state.ts
Normal file
9
lib/services/veeam-analysis-state.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Shared in-process state for the background ticket analysis runner.
|
||||
// Works because we run in Docker (long-lived Node process), not serverless.
|
||||
export const analysisState = {
|
||||
isRunning: false,
|
||||
total: 0,
|
||||
done: 0,
|
||||
errors: 0,
|
||||
startedAt: null as Date | null,
|
||||
};
|
||||
|
|
@ -6,6 +6,12 @@
|
|||
|
||||
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
|
||||
|
|
@ -16,12 +22,17 @@ 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;
|
||||
}
|
||||
|
|
@ -36,8 +47,12 @@ export interface RpoJobSummary {
|
|||
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;
|
||||
|
|
@ -138,11 +153,12 @@ export class VeeamRpoService {
|
|||
escalated: 0,
|
||||
resolved: 0,
|
||||
skipped: 0,
|
||||
offlineSuppressed: 0,
|
||||
errors: [],
|
||||
runAt: new Date(),
|
||||
};
|
||||
|
||||
const client = getAutotaskClient();
|
||||
const client = SHADOW_MODE ? null : getAutotaskClient();
|
||||
|
||||
// Fetch all enabled workstation jobs with org info
|
||||
const jobsRes = await postgresClient.query(`
|
||||
|
|
@ -169,27 +185,32 @@ export class VeeamRpoService {
|
|||
const jobs = jobsRes.rows;
|
||||
result.checked = jobs.length;
|
||||
|
||||
// Fetch all currently open RPO tickets in one query
|
||||
const openTicketsRes = await postgresClient.query(`
|
||||
SELECT * FROM veeam_rpo_tickets WHERE resolved_at IS NULL
|
||||
`);
|
||||
// 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 {
|
||||
await this.processJob(job, openByJobUid, client, result);
|
||||
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 processed jobs
|
||||
await postgresClient.query(`
|
||||
UPDATE veeam_rpo_tickets SET last_checked_at = NOW() WHERE resolved_at IS NULL
|
||||
`);
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -197,6 +218,7 @@ export class VeeamRpoService {
|
|||
private async processJob(
|
||||
job: any,
|
||||
openByJobUid: Record<string, any>,
|
||||
rmmDevice: RmmDeviceInfo | null,
|
||||
client: AutotaskClient,
|
||||
result: RpoCheckResult,
|
||||
): Promise<void> {
|
||||
|
|
@ -214,6 +236,24 @@ export class VeeamRpoService {
|
|||
: (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
|
||||
|
|
@ -226,8 +266,12 @@ export class VeeamRpoService {
|
|||
|
||||
if (!isBreached) {
|
||||
if (openTicket) {
|
||||
await this.resolveTicket(openTicket, client);
|
||||
result.resolved++;
|
||||
if (SHADOW_MODE) {
|
||||
await this.shadowResolve(openTicket, job, rmmDevice, result);
|
||||
} else {
|
||||
await this.resolveTicket(openTicket, client!);
|
||||
result.resolved++;
|
||||
}
|
||||
} else {
|
||||
result.skipped++;
|
||||
}
|
||||
|
|
@ -248,15 +292,22 @@ export class VeeamRpoService {
|
|||
|
||||
if (!openTicket) {
|
||||
if (tooOldForNewTicket) {
|
||||
if (SHADOW_MODE) await this.writeShadowLog(job, rmmDevice, 'would_skip_too_old', null, null, null);
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
// Create new ticket
|
||||
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client, result);
|
||||
if (SHADOW_MODE) {
|
||||
await this.shadowCreate(job, rmmDevice, hoursOverdue, failureCategory, targetPriority, result);
|
||||
} else {
|
||||
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client!, result);
|
||||
}
|
||||
} else {
|
||||
// Escalate if needed
|
||||
if (openTicket.priority_level !== targetPriority && this.isPriorityHigher(targetPriority, openTicket.priority_level)) {
|
||||
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client, result);
|
||||
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++;
|
||||
}
|
||||
|
|
@ -268,6 +319,130 @@ export class VeeamRpoService {
|
|||
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,
|
||||
|
|
@ -289,9 +464,8 @@ export class VeeamRpoService {
|
|||
|
||||
// Look up Autotask company ID from org name mapping
|
||||
const companyRes = await postgresClient.query(`
|
||||
SELECT c.id FROM companies c
|
||||
JOIN veeam_organizations vo ON vo.autotask_company_id = c.id
|
||||
WHERE vo.name = $1
|
||||
SELECT company_id AS id FROM veeam_organizations
|
||||
WHERE name = $1
|
||||
LIMIT 1
|
||||
`, [job.org_name]);
|
||||
|
||||
|
|
@ -303,7 +477,6 @@ export class VeeamRpoService {
|
|||
status: AT_STATUS_NEW,
|
||||
queueID: AT_QUEUE_ID,
|
||||
issueType: AT_ISSUE_TYPE,
|
||||
subIssueType: AT_SUB_ISSUE,
|
||||
priority: atPriority,
|
||||
};
|
||||
if (companyId) ticketPayload.companyID = companyId;
|
||||
|
|
@ -398,7 +571,7 @@ export class VeeamRpoService {
|
|||
console.log(`[RPO] Resolved ticket ${openTicket.at_ticket_number} — backup succeeded`);
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ summary: Record<string, number>; jobs: RpoJobSummary[] }> {
|
||||
async getStatus(): Promise<{ shadowMode: boolean; summary: Record<string, number>; jobs: RpoJobSummary[] }> {
|
||||
const jobsRes = await postgresClient.query(`
|
||||
SELECT
|
||||
j.instance_uid,
|
||||
|
|
@ -411,7 +584,12 @@ export class VeeamRpoService {
|
|||
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
|
||||
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
|
||||
|
|
@ -421,7 +599,10 @@ export class VeeamRpoService {
|
|||
ORDER BY hours_since_backup DESC NULLS LAST
|
||||
`);
|
||||
|
||||
const jobs: RpoJobSummary[] = jobsRes.rows.map((row) => {
|
||||
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
|
||||
|
|
@ -432,39 +613,52 @@ export class VeeamRpoService {
|
|||
&& (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,
|
||||
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
|
||||
failure_message: row.failure_message,
|
||||
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 as any).at_ticket_id,
|
||||
at_ticket_number: (row as any).at_ticket_number,
|
||||
priority_level: (row as any).priority_level,
|
||||
hours_overdue: (row as any).hours_overdue,
|
||||
opened_at: (row as any).opened_at,
|
||||
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 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;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export class WebhookService {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventId: payload.eventId,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export enum EntityType {
|
|||
PROJECT_PHASES = 'project_phases',
|
||||
COMPANY_CATEGORIES = 'company_categories',
|
||||
COMPANY_TYPES = 'company_types',
|
||||
COMPANY_TEAMS = 'company_teams',
|
||||
}
|
||||
|
||||
// Sync operation types
|
||||
|
|
@ -181,6 +182,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
|||
[EntityType.PROJECT_PHASES]: [EntityType.PROJECTS], // Depends on projects
|
||||
[EntityType.COMPANY_CATEGORIES]: [], // No dependencies — standalone lookup
|
||||
[EntityType.COMPANY_TYPES]: [], // No dependencies — standalone lookup
|
||||
[EntityType.COMPANY_TEAMS]: [EntityType.COMPANIES, EntityType.RESOURCES], // Depends on companies and resources
|
||||
};
|
||||
|
||||
// Autotask API field names (for incremental sync)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export function mapAutotaskToDatabase(
|
|||
case EntityType.TAGS:
|
||||
mapped = mapTag(data);
|
||||
break;
|
||||
case EntityType.COMPANY_TEAMS:
|
||||
mapped = mapCompanyTeam(data);
|
||||
break;
|
||||
default:
|
||||
// Fallback: auto-convert camelCase to snake_case
|
||||
mapped = {};
|
||||
|
|
@ -826,6 +829,19 @@ function mapPicklist(data: any): Record<string, any> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CompanyTeam entity
|
||||
*/
|
||||
function mapCompanyTeam(data: any): Record<string, any> {
|
||||
return {
|
||||
id: data.id,
|
||||
company_id: data.companyID,
|
||||
resource_id: data.resourceID,
|
||||
is_associated_as_comanaged: data.isAssociatedAsComanaged || false,
|
||||
is_deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch map multiple entities
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export function getAllEntitiesInOrder(): EntityType[] {
|
|||
EntityType.TIME_ENTRIES,
|
||||
EntityType.TAG_GROUPS,
|
||||
EntityType.TAGS,
|
||||
EntityType.COMPANY_TEAMS,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +131,7 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
|||
[EntityType.PROJECT_PHASES]: 'Phases',
|
||||
[EntityType.COMPANY_CATEGORIES]: 'CompanyCategories',
|
||||
[EntityType.COMPANY_TYPES]: 'CompanyTypes',
|
||||
[EntityType.COMPANY_TEAMS]: 'CompanyTeams',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
@ -185,6 +187,7 @@ export function getLastModifiedField(entity: EntityType): string {
|
|||
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
|
||||
[EntityType.COMPANY_CATEGORIES]: 'lastModifiedDate',
|
||||
[EntityType.COMPANY_TYPES]: 'lastModifiedDate',
|
||||
[EntityType.COMPANY_TEAMS]: 'lastModifiedDate', // No date field; incremental not supported
|
||||
};
|
||||
|
||||
return mapping[entity] || 'lastModifiedDate';
|
||||
|
|
@ -222,6 +225,7 @@ export function getActiveField(entity: EntityType): string | null {
|
|||
[EntityType.PROJECT_PHASES]: null, // No active field on phases
|
||||
[EntityType.COMPANY_CATEGORIES]: 'isActive',
|
||||
[EntityType.COMPANY_TYPES]: 'isActive',
|
||||
[EntityType.COMPANY_TEAMS]: null,
|
||||
};
|
||||
|
||||
return mapping[entity] || null;
|
||||
|
|
@ -319,6 +323,7 @@ export function buildDateRangeFilter(
|
|||
[EntityType.PROJECT_PHASES]: null,
|
||||
[EntityType.COMPANY_CATEGORIES]: null,
|
||||
[EntityType.COMPANY_TYPES]: null,
|
||||
[EntityType.COMPANY_TEAMS]: null,
|
||||
};
|
||||
|
||||
const dateField = dateFieldMapping[entity];
|
||||
|
|
@ -529,6 +534,7 @@ export function getEntityDisplayName(entity: EntityType): string {
|
|||
[EntityType.PROJECT_PHASES]: 'Project Phases',
|
||||
[EntityType.COMPANY_CATEGORIES]: 'Company Categories',
|
||||
[EntityType.COMPANY_TYPES]: 'Company Types',
|
||||
[EntityType.COMPANY_TEAMS]: 'Company Teams',
|
||||
};
|
||||
|
||||
return mapping[entity] || entity;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue