feat: Veeam VSPC backup integration - sync, compliance, UI

- Database: 7 Veeam tables + backup_type_udf column on configuration_items
- API Client: VSPC REST API v3 client with pagination, rate limiting, Bearer auth
- Sync Service: full/incremental sync for orgs, servers, repos, jobs, agent jobs, workloads
- Scheduler: veeam-incremental (30min) and veeam-full (daily 2AM) schedules
- Compliance Engine: cross-references Autotask config items vs Veeam workloads
- API Endpoints: backup-status, companies, workloads, jobs, repos, compliance, sync
- UI: Backup Status page with Overview + Contract Compliance tabs
- Navigation: added Backup Status link with HardDrive icon
- Docker: added VEEAM_VSPC_URL and VEEAM_VSPC_API_KEY env vars to compose
This commit is contained in:
lorentz 2026-02-11 21:04:28 -05:00
parent a1e0e7c7c0
commit 5dc7a7e66b
27 changed files with 3408 additions and 7 deletions

View file

@ -7,13 +7,14 @@ import cron, { ScheduledTask } from 'node-cron';
import { SyncService, createSyncService } from './sync-service';
import { postgresClient } from './postgres-client';
import { AutotaskClient } from './autotask-client';
import { VeeamSyncService } from './veeam-sync-service';
export interface ScheduleConfig {
id: string;
name: string;
description: string;
cron_expression: string;
sync_type: 'incremental' | 'full';
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full';
years_back?: number;
is_enabled: boolean;
last_run?: Date;
@ -36,6 +37,14 @@ class SyncScheduler {
private runningJobs: Set<string> = new Set();
private initialized = false;
private syncService: SyncService;
private _veeamSyncService: VeeamSyncService | null = null;
private getVeeamSyncService(): VeeamSyncService {
if (!this._veeamSyncService) {
this._veeamSyncService = new VeeamSyncService();
}
return this._veeamSyncService;
}
constructor() {
// Create sync service instance
@ -87,7 +96,7 @@ class SyncScheduler {
name VARCHAR(100) NOT NULL,
description TEXT,
cron_expression VARCHAR(50) NOT NULL,
sync_type VARCHAR(20) NOT NULL CHECK (sync_type IN ('incremental', 'full')),
sync_type VARCHAR(30) NOT NULL CHECK (sync_type IN ('incremental', 'full', 'veeam-incremental', 'veeam-full')),
years_back INTEGER DEFAULT 2,
is_enabled BOOLEAN NOT NULL DEFAULT true,
last_run TIMESTAMP,
@ -127,7 +136,7 @@ class SyncScheduler {
description: 'Syncs changes from the last 24 hours every day at 2 AM',
cron_expression: '0 2 * * *',
sync_type: 'incremental',
is_enabled: false, // Disabled by default - user must enable
is_enabled: false,
},
{
id: 'weekly-full',
@ -136,14 +145,31 @@ class SyncScheduler {
cron_expression: '0 3 * * 0',
sync_type: 'full',
years_back: 2,
is_enabled: false, // Disabled by default - user must enable
is_enabled: false,
},
{
id: 'veeam-incremental',
name: 'Veeam Incremental Sync',
description: 'Syncs Veeam backup data every 30 minutes',
cron_expression: '*/30 * * * *',
sync_type: 'veeam-incremental',
is_enabled: false,
},
{
id: 'veeam-full',
name: 'Veeam Full Sync',
description: 'Full Veeam backup data sync daily at 2:00 AM',
cron_expression: '0 2 * * *',
sync_type: 'veeam-full',
is_enabled: false,
},
];
for (const schedule of defaultSchedules) {
await postgresClient.query(
`INSERT INTO sync_schedules (id, name, description, cron_expression, sync_type, years_back, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (id) DO NOTHING`,
[
schedule.id,
schedule.name,
@ -240,7 +266,11 @@ class SyncScheduler {
);
// Execute the sync
if (config.sync_type === 'incremental') {
if (config.sync_type === 'veeam-incremental') {
await this.getVeeamSyncService().incrementalSync('scheduled');
} else if (config.sync_type === 'veeam-full') {
await this.getVeeamSyncService().fullSync('scheduled');
} else if (config.sync_type === 'incremental') {
await this.syncService.incrementalSync('scheduled');
} else {
await this.syncService.fullSync('scheduled', config.years_back || 2);

View file

@ -0,0 +1,226 @@
import {
VspcListResponse,
VspcOrganization,
VspcBackupServer,
VspcBackupJob,
VspcBackupAgentJob,
VspcProtectedWorkload,
VspcRepository,
} from '@/lib/types/veeam';
export interface VeeamClientConfig {
baseUrl: string;
apiKey: string;
}
export class VeeamClient {
private config: VeeamClientConfig;
private requestTimestamps: number[] = [];
private readonly RATE_LIMIT = 100; // requests per minute
private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute in milliseconds
private readonly DEFAULT_PAGE_SIZE = 500;
constructor(config: VeeamClientConfig) {
this.config = config;
}
/**
* Get authorization headers for VSPC API
*/
private getAuthHeaders(): HeadersInit {
return {
'Authorization': `Bearer ${this.config.apiKey}`,
'Accept': 'application/json',
'Content-Type': 'application/json',
};
}
/**
* Check and enforce rate limiting
*/
private async checkRateLimit(): Promise<void> {
const now = Date.now();
this.requestTimestamps = this.requestTimestamps.filter(
(timestamp) => now - timestamp < this.RATE_LIMIT_WINDOW
);
if (this.requestTimestamps.length >= this.RATE_LIMIT) {
const oldestInWindow = this.requestTimestamps[0];
const waitMs = this.RATE_LIMIT_WINDOW - (now - oldestInWindow) + 100;
console.warn(`Veeam VSPC rate limit reached (${this.RATE_LIMIT}/min), waiting ${waitMs}ms`);
await new Promise(resolve => setTimeout(resolve, waitMs));
}
this.requestTimestamps.push(Date.now());
}
/**
* Make a single API call with error handling and rate limiting
*/
private async makeApiCall<T>(url: string): Promise<T> {
await this.checkRateLimit();
try {
const response = await fetch(url, {
method: 'GET',
headers: this.getAuthHeaders(),
// VSPC may use self-signed certs
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
});
if (!response.ok) {
const errorText = await response.text();
let errorDetail = errorText;
try {
const errorJson = JSON.parse(errorText);
errorDetail = JSON.stringify(errorJson.errors || errorJson, null, 2);
} catch {
// keep raw text
}
console.error(`Veeam VSPC API error: ${response.status} ${response.statusText}`, errorDetail);
throw new Error(`Veeam VSPC API request failed: ${response.status} ${response.statusText} - ${errorDetail}`);
}
const text = await response.text();
if (!text) {
return { meta: { pagingInfo: { total: 0, count: 0, offset: 0 } }, data: [] } as T;
}
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof Error && error.message.startsWith('Veeam VSPC API request failed')) {
throw error;
}
console.error('Veeam VSPC API call failed:', error);
throw error;
}
}
/**
* Build a URL with query parameters
*/
private buildUrl(path: string, params: Record<string, string | number> = {}): string {
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
const fullUrl = `${baseUrl}/api/v3${path}`;
const url = new URL(fullUrl);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
/**
* Fetch all pages of a paginated VSPC API endpoint
*/
private async fetchAllPages<T>(path: string, filter?: string): Promise<T[]> {
const allItems: T[] = [];
let offset = 0;
let total = 0;
do {
const params: Record<string, string | number> = {
offset,
limit: this.DEFAULT_PAGE_SIZE,
};
if (filter) {
params.filter = filter;
}
const url = this.buildUrl(path, params);
const response = await this.makeApiCall<VspcListResponse<T>>(url);
if (response.data && response.data.length > 0) {
allItems.push(...response.data);
}
total = response.meta?.pagingInfo?.total ?? 0;
offset += this.DEFAULT_PAGE_SIZE;
if (response.data.length > 0) {
console.log(`Veeam VSPC: fetched ${allItems.length}/${total} from ${path}`);
}
} while (offset < total);
return allItems;
}
// ============================================================================
// Data Fetching Methods
// ============================================================================
/**
* Fetch all organizations (tenants/companies)
*/
async getOrganizations(): Promise<VspcOrganization[]> {
console.log('Fetching Veeam VSPC organizations...');
const orgs = await this.fetchAllPages<VspcOrganization>('/organizations');
console.log(`Fetched ${orgs.length} Veeam organizations`);
return orgs;
}
/**
* Fetch all backup servers
*/
async getBackupServers(): Promise<VspcBackupServer[]> {
console.log('Fetching Veeam VSPC backup servers...');
const servers = await this.fetchAllPages<VspcBackupServer>('/infrastructure/backupServers');
console.log(`Fetched ${servers.length} Veeam backup servers`);
return servers;
}
/**
* Fetch all backup server jobs (VM-level backup jobs)
*/
async getBackupJobs(filter?: string): Promise<VspcBackupJob[]> {
console.log('Fetching Veeam VSPC backup server jobs...');
const jobs = await this.fetchAllPages<VspcBackupJob>('/infrastructure/backupServers/jobs', filter);
console.log(`Fetched ${jobs.length} Veeam backup server jobs`);
return jobs;
}
/**
* Fetch all backup agent jobs (workstation/physical server backup jobs)
*/
async getBackupAgentJobs(filter?: string): Promise<VspcBackupAgentJob[]> {
console.log('Fetching Veeam VSPC backup agent jobs...');
const jobs = await this.fetchAllPages<VspcBackupAgentJob>('/infrastructure/backupAgents/jobs', filter);
console.log(`Fetched ${jobs.length} Veeam backup agent jobs`);
return jobs;
}
/**
* Fetch all protected workloads (virtual machines)
*/
async getProtectedWorkloads(): Promise<VspcProtectedWorkload[]> {
console.log('Fetching Veeam VSPC protected workloads...');
const workloads = await this.fetchAllPages<VspcProtectedWorkload>('/protectedWorkloads/virtualMachines');
console.log(`Fetched ${workloads.length} Veeam protected workloads`);
return workloads;
}
/**
* Fetch all repositories
*/
async getRepositories(): Promise<VspcRepository[]> {
console.log('Fetching Veeam VSPC repositories...');
const repos = await this.fetchAllPages<VspcRepository>('/infrastructure/backupServers/repositories');
console.log(`Fetched ${repos.length} Veeam repositories`);
return repos;
}
/**
* Test API connectivity by fetching a single organization
*/
async testConnection(): Promise<boolean> {
try {
const url = this.buildUrl('/organizations', { limit: 1 });
const response = await this.makeApiCall<VspcListResponse<VspcOrganization>>(url);
const total = response.meta?.pagingInfo?.total ?? 0;
console.log(`Veeam VSPC connection test successful: ${total} organizations available`);
return true;
} catch (error) {
console.error('Veeam VSPC connection test failed:', error);
return false;
}
}
}

View file

@ -0,0 +1,203 @@
/**
* Veeam Compliance Service
* Cross-references Autotask config items (with backup UDFs on active contracts)
* against Veeam protected workloads to identify mismatches.
*/
import postgresClient from './postgres-client';
export interface ComplianceComputeResult {
totalContracted: number;
matched: number;
contractedNotBackedUp: number;
backedUpNotContracted: number;
duration: number;
}
export class VeeamComplianceService {
/**
* Compute compliance results by cross-referencing Autotask config items
* with Veeam protected workloads and agent jobs.
*/
async computeCompliance(): Promise<ComplianceComputeResult> {
const startTime = Date.now();
console.log('[VEEAM-COMPLIANCE] Starting compliance computation...');
// 1. Get all contracted backup devices from Autotask
// A device is "contracted for backup" when:
// - backup_type_udf IS NOT NULL and NOT empty
// - is_active = true
// - The company has at least one active contract (status = 1)
// Note: configuration_items don't have a direct contract_id column;
// we match via company having active contracts instead.
const contractedDevices = await postgresClient.query(`
SELECT
ci.id as configuration_item_id,
ci.company_id,
ci.reference_title,
ci.backup_type_udf,
ct.contract_name
FROM configuration_items ci
JOIN contracts ct ON ct.company_id = ci.company_id AND ct.status = 1
WHERE ci.backup_type_udf IS NOT NULL
AND ci.backup_type_udf != ''
AND ci.is_active = true
AND ci.is_deleted = false
GROUP BY ci.id, ci.company_id, ci.reference_title, ci.backup_type_udf, ct.contract_name
`);
console.log(`[VEEAM-COMPLIANCE] Found ${contractedDevices.rows.length} contracted backup devices`);
// 2. Get all Veeam protected workloads with their org's company_id
const veeamWorkloads = await postgresClient.query(`
SELECT
pw.instance_uid,
pw.name,
pw.organization_uid,
vo.company_id
FROM veeam_protected_workloads pw
LEFT JOIN veeam_organizations vo ON vo.instance_uid = pw.organization_uid
`);
// 3. Get all Veeam agent jobs with their org's company_id
const veeamAgentJobs = await postgresClient.query(`
SELECT
aj.instance_uid,
aj.name,
aj.organization_uid,
vo.company_id
FROM veeam_backup_agent_jobs aj
LEFT JOIN veeam_organizations vo ON vo.instance_uid = aj.organization_uid
WHERE aj.is_enabled = true
`);
// Build lookup maps for Veeam data (lowercase name → record)
const veeamByName = new Map<string, { uid: string; name: string; companyId: number | null }>();
for (const w of veeamWorkloads.rows) {
if (w.name) {
veeamByName.set(w.name.toLowerCase(), {
uid: w.instance_uid,
name: w.name,
companyId: w.company_id,
});
}
}
for (const j of veeamAgentJobs.rows) {
if (j.name) {
// Agent job names often have policy prefix, try to extract hostname
const name = j.name.toLowerCase();
if (!veeamByName.has(name)) {
veeamByName.set(name, {
uid: j.instance_uid,
name: j.name,
companyId: j.company_id,
});
}
}
}
// Track which Veeam workloads are matched
const matchedVeeamUids = new Set<string>();
// 4. Match contracted devices to Veeam workloads
const contractedNotBackedUp: Array<{
companyId: number; configItemId: number; deviceName: string;
backupTypeUdf: string; contractName: string;
}> = [];
for (const device of contractedDevices.rows) {
const hostname = (device.reference_title || '').toLowerCase().trim();
if (!hostname) {
contractedNotBackedUp.push({
companyId: device.company_id,
configItemId: device.configuration_item_id,
deviceName: device.reference_title || 'Unknown',
backupTypeUdf: device.backup_type_udf,
contractName: device.contract_name || '',
});
continue;
}
// Try exact match first
let match = veeamByName.get(hostname);
// Try partial match (hostname contained in workload name or vice versa)
if (!match) {
for (const [wName, wData] of veeamByName) {
if (wName.includes(hostname) || hostname.includes(wName)) {
// Prefer same-company match
if (wData.companyId === device.company_id) {
match = wData;
break;
}
if (!match) match = wData;
}
}
}
if (match) {
matchedVeeamUids.add(match.uid);
} else {
contractedNotBackedUp.push({
companyId: device.company_id,
configItemId: device.configuration_item_id,
deviceName: device.reference_title || 'Unknown',
backupTypeUdf: device.backup_type_udf,
contractName: device.contract_name || '',
});
}
}
// 5. Find Veeam workloads not matched to any contracted device
const backedUpNotContracted: Array<{
companyId: number | null; veeamUid: string; workloadName: string;
}> = [];
for (const w of veeamWorkloads.rows) {
if (!matchedVeeamUids.has(w.instance_uid)) {
backedUpNotContracted.push({
companyId: w.company_id,
veeamUid: w.instance_uid,
workloadName: w.name,
});
}
}
// 6. Truncate and reinsert compliance results
await postgresClient.query('DELETE FROM veeam_compliance_results');
for (const item of contractedNotBackedUp) {
await postgresClient.query(
`INSERT INTO veeam_compliance_results (company_id, configuration_item_id, mismatch_type, backup_type_udf, device_name, contract_name, computed_at)
VALUES ($1, $2, 'contracted_not_backed_up', $3, $4, $5, NOW())`,
[item.companyId, item.configItemId, item.backupTypeUdf, item.deviceName, item.contractName]
);
}
for (const item of backedUpNotContracted) {
await postgresClient.query(
`INSERT INTO veeam_compliance_results (company_id, veeam_workload_uid, mismatch_type, device_name, veeam_workload_name, computed_at)
VALUES ($1, $2, 'backed_up_not_contracted', $3, $3, NOW())`,
[item.companyId, item.veeamUid, item.workloadName]
);
}
const duration = Date.now() - startTime;
const totalContracted = contractedDevices.rows.length;
const matched = totalContracted - contractedNotBackedUp.length;
console.log(`[VEEAM-COMPLIANCE] Compliance computed in ${duration}ms:`);
console.log(` Contracted: ${totalContracted}, Matched: ${matched}`);
console.log(` Missing backup: ${contractedNotBackedUp.length}`);
console.log(` No contract: ${backedUpNotContracted.length}`);
return {
totalContracted,
matched,
contractedNotBackedUp: contractedNotBackedUp.length,
backedUpNotContracted: backedUpNotContracted.length,
duration,
};
}
}

View file

@ -0,0 +1,41 @@
import { VeeamClient, VeeamClientConfig } from './veeam-client';
let veeamClientInstance: VeeamClient | null = null;
/**
* Check if Veeam VSPC credentials are configured
*/
export function isVeeamConfigured(): boolean {
return !!(process.env.VEEAM_VSPC_URL && process.env.VEEAM_VSPC_API_KEY);
}
/**
* Get or create Veeam VSPC client singleton instance
*/
export function getVeeamClient(): VeeamClient {
if (!veeamClientInstance) {
const config: VeeamClientConfig = {
baseUrl: process.env.VEEAM_VSPC_URL || '',
apiKey: process.env.VEEAM_VSPC_API_KEY || '',
};
// Validate configuration
if (!config.baseUrl || !config.apiKey) {
throw new Error(
'Veeam VSPC API credentials missing. Please set VEEAM_VSPC_URL and VEEAM_VSPC_API_KEY environment variables.'
);
}
veeamClientInstance = new VeeamClient(config);
console.log('Veeam VSPC client initialized');
}
return veeamClientInstance;
}
/**
* Reset the singleton instance (useful for testing)
*/
export function resetVeeamClient(): void {
veeamClientInstance = null;
}

View file

@ -0,0 +1,402 @@
/**
* Veeam Sync Service
* Orchestrates syncing VSPC data to PostgreSQL
*/
import postgresClient from './postgres-client';
import { VeeamClient } from './veeam-client';
import { getVeeamClient } from './veeam-factory';
import {
VspcOrganization,
VspcBackupServer,
VspcBackupJob,
VspcBackupAgentJob,
VspcProtectedWorkload,
VspcRepository,
} from '@/lib/types/veeam';
import { VeeamComplianceService } from './veeam-compliance-service';
export interface VeeamSyncResult {
syncId: string;
syncType: 'full' | 'incremental';
status: 'completed' | 'failed';
startedAt: Date;
completedAt: Date;
duration: number;
entities: VeeamEntitySyncResult[];
errors: string[];
}
export interface VeeamEntitySyncResult {
entity: string;
success: boolean;
recordsUpserted: number;
duration: number;
error?: string;
}
export class VeeamSyncService {
private client: VeeamClient;
private isSyncing = false;
constructor(client?: VeeamClient) {
this.client = client || getVeeamClient();
}
isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Full sync fetches all entities and upserts into PostgreSQL
*/
async fullSync(triggeredBy: string = 'system'): Promise<VeeamSyncResult> {
return this.executeSync('full', triggeredBy);
}
/**
* Incremental sync same as full for now since VSPC API doesn't support
* filtering by last-modified. We upsert all records so unchanged rows are no-ops.
*/
async incrementalSync(triggeredBy: string = 'system'): Promise<VeeamSyncResult> {
return this.executeSync('incremental', triggeredBy);
}
private async executeSync(syncType: 'full' | 'incremental', triggeredBy: string): Promise<VeeamSyncResult> {
if (this.isSyncing) {
throw new Error('A Veeam sync operation is already in progress');
}
this.isSyncing = true;
const syncId = `veeam-${Date.now()}`;
const startTime = new Date();
const entityResults: VeeamEntitySyncResult[] = [];
const errors: string[] = [];
// Create sync history record
let historyId: number | null = null;
try {
const histResult = await postgresClient.query<{ id: number }>(
`INSERT INTO sync_history (entity_type, sync_type, status, started_at, records_added, records_updated, records_deleted, triggered_by)
VALUES ($1, $2, $3, $4, 0, 0, 0, $5) RETURNING id`,
['veeam', syncType, 'started', startTime, triggeredBy]
);
historyId = histResult.rows[0].id;
} catch (e) {
console.warn('[VEEAM-SYNC] Could not create sync history record:', e);
}
console.log(`[VEEAM-SYNC] Starting ${syncType} sync (${syncId})`);
try {
// Sync in dependency order
const steps: Array<{ name: string; fn: () => Promise<number> }> = [
{ name: 'organizations', fn: () => this.syncOrganizations() },
{ name: 'backup_servers', fn: () => this.syncBackupServers() },
{ name: 'repositories', fn: () => this.syncRepositories() },
{ name: 'backup_jobs', fn: () => this.syncBackupJobs() },
{ name: 'backup_agent_jobs', fn: () => this.syncBackupAgentJobs() },
{ name: 'protected_workloads', fn: () => this.syncProtectedWorkloads() },
];
for (const step of steps) {
const stepStart = Date.now();
try {
const count = await step.fn();
const duration = Date.now() - stepStart;
entityResults.push({ entity: step.name, success: true, recordsUpserted: count, duration });
console.log(`[VEEAM-SYNC] ${step.name}: ${count} records in ${duration}ms`);
} catch (error) {
const duration = Date.now() - stepStart;
const msg = error instanceof Error ? error.message : String(error);
errors.push(`${step.name}: ${msg}`);
entityResults.push({ entity: step.name, success: false, recordsUpserted: 0, duration, error: msg });
console.error(`[VEEAM-SYNC] ${step.name} failed:`, msg);
}
}
const completedAt = new Date();
const duration = completedAt.getTime() - startTime.getTime();
const status = errors.length === 0 ? 'completed' : 'failed';
const totalRecords = entityResults.reduce((sum, r) => sum + r.recordsUpserted, 0);
console.log(`[VEEAM-SYNC] Sync ${status} in ${duration}ms — ${totalRecords} total records`);
// Run compliance computation after successful sync
if (status === 'completed') {
try {
const complianceService = new VeeamComplianceService();
await complianceService.computeCompliance();
} catch (compError) {
console.error('[VEEAM-SYNC] Compliance computation failed:', compError);
}
}
// Update sync history
if (historyId) {
try {
await postgresClient.query(
`UPDATE sync_history SET status = $1, completed_at = $2, records_added = $3, error_message = $4 WHERE id = $5`,
[status, completedAt, totalRecords, errors.length > 0 ? errors.join('; ') : null, historyId]
);
} catch (e) {
console.warn('[VEEAM-SYNC] Could not update sync history:', e);
}
}
return { syncId, syncType, status, startedAt: startTime, completedAt, duration, entities: entityResults, errors };
} catch (error) {
const completedAt = new Date();
const msg = error instanceof Error ? error.message : String(error);
console.error('[VEEAM-SYNC] Sync failed catastrophically:', msg);
if (historyId) {
try {
await postgresClient.query(
`UPDATE sync_history SET status = 'failed', completed_at = $1, error_message = $2 WHERE id = $3`,
[completedAt, msg, historyId]
);
} catch (e) { /* ignore */ }
}
return {
syncId, syncType, status: 'failed', startedAt: startTime, completedAt,
duration: completedAt.getTime() - startTime.getTime(), entities: entityResults, errors: [msg],
};
} finally {
this.isSyncing = false;
}
}
// ============================================================================
// Entity Sync Methods
// ============================================================================
private async syncOrganizations(): Promise<number> {
const orgs = await this.client.getOrganizations();
if (orgs.length === 0) return 0;
let count = 0;
for (const org of orgs) {
const parsed = org.companyId ? parseInt(org.companyId, 10) : null;
const companyId = parsed && !isNaN(parsed) ? parsed : null;
// Verify company exists in Autotask if companyId is set
let matchedCompanyId = companyId;
if (companyId) {
const check = await postgresClient.query(
'SELECT id FROM companies WHERE id = $1', [companyId]
);
if (check.rows.length === 0) {
matchedCompanyId = null;
}
}
await postgresClient.query(
`INSERT INTO veeam_organizations (instance_uid, name, type, company_id, tax_id, email, phone, country, state, city, street, zip_code, website, notes, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
name=EXCLUDED.name, type=EXCLUDED.type, company_id=EXCLUDED.company_id,
tax_id=EXCLUDED.tax_id, email=EXCLUDED.email, phone=EXCLUDED.phone,
country=EXCLUDED.country, state=EXCLUDED.state, city=EXCLUDED.city,
street=EXCLUDED.street, zip_code=EXCLUDED.zip_code, website=EXCLUDED.website,
notes=EXCLUDED.notes, synced_at=NOW(), updated_at=NOW()`,
[
org.instanceUid, org.name, org.type, matchedCompanyId,
org.taxId || null, org.email || null, org.phone || null,
org.countryName || null, org.regionName || null, org.city || null,
org.street || null, org.zipCode || null, org.website || null, org.notes || null,
]
);
count++;
}
return count;
}
private async syncBackupServers(): Promise<number> {
const servers = await this.client.getBackupServers();
if (servers.length === 0) return 0;
// Build set of known org UIDs for FK safety
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const s of servers) {
const orgUid = orgUids.has(s.organizationUid) ? s.organizationUid : null;
await postgresClient.query(
`INSERT INTO veeam_backup_servers (instance_uid, name, organization_uid, version, display_version, status, backup_server_role_type, installation_uid, location_uid, management_agent_uid, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
name=EXCLUDED.name, organization_uid=EXCLUDED.organization_uid,
version=EXCLUDED.version, display_version=EXCLUDED.display_version,
status=EXCLUDED.status, backup_server_role_type=EXCLUDED.backup_server_role_type,
installation_uid=EXCLUDED.installation_uid, location_uid=EXCLUDED.location_uid,
management_agent_uid=EXCLUDED.management_agent_uid, synced_at=NOW(), updated_at=NOW()`,
[
s.instanceUid, s.name, orgUid, s.version, s.displayVersion,
s.status, s.backupServerRoleType, s.installationUid, s.locationUid, s.managementAgentUid,
]
);
count++;
}
return count;
}
private async syncBackupJobs(): Promise<number> {
const jobs = await this.client.getBackupJobs();
if (jobs.length === 0) return 0;
// Build sets for FK safety
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
const knownServers = await postgresClient.query('SELECT instance_uid FROM veeam_backup_servers');
const serverUids = new Set(knownServers.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const j of jobs) {
const orgUid = orgUids.has(j.organizationUid) ? j.organizationUid : null;
const serverUid = serverUids.has(j.backupServerUid) ? j.backupServerUid : null;
await postgresClient.query(
`INSERT INTO veeam_backup_jobs (instance_uid, unique_uid, name, description, backup_server_uid, organization_uid, status, type, last_run, last_end_time, last_duration, processing_rate, avg_duration, transferred_data, backup_chain_size, bottleneck, is_enabled, schedule_type, failure_message, target_type, destination, retention_limit, retention_limit_type, is_gfs_option_enabled, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
unique_uid=EXCLUDED.unique_uid, name=EXCLUDED.name, description=EXCLUDED.description,
backup_server_uid=EXCLUDED.backup_server_uid, organization_uid=EXCLUDED.organization_uid,
status=EXCLUDED.status, type=EXCLUDED.type, last_run=EXCLUDED.last_run,
last_end_time=EXCLUDED.last_end_time, last_duration=EXCLUDED.last_duration,
processing_rate=EXCLUDED.processing_rate, avg_duration=EXCLUDED.avg_duration,
transferred_data=EXCLUDED.transferred_data, backup_chain_size=EXCLUDED.backup_chain_size,
bottleneck=EXCLUDED.bottleneck, is_enabled=EXCLUDED.is_enabled,
schedule_type=EXCLUDED.schedule_type, failure_message=EXCLUDED.failure_message,
target_type=EXCLUDED.target_type, destination=EXCLUDED.destination,
retention_limit=EXCLUDED.retention_limit, retention_limit_type=EXCLUDED.retention_limit_type,
is_gfs_option_enabled=EXCLUDED.is_gfs_option_enabled, synced_at=NOW(), updated_at=NOW()`,
[
j.instanceUid, j.uniqueUid, j.name, j.description || null,
serverUid, orgUid, j.status, j.type,
j.lastRun || null, j.lastEndTime || null, j.lastDuration, j.processingRate,
j.avgDuration, j.transferredData, j.backupChainSize, j.bottleneck,
j.isEnabled, j.scheduleType, j.failureMessage || null,
j.targetType, j.destination, j.retentionLimit, j.retentionLimitType,
j.isGfsOptionEnabled,
]
);
count++;
}
return count;
}
private async syncBackupAgentJobs(): Promise<number> {
const jobs = await this.client.getBackupAgentJobs();
if (jobs.length === 0) return 0;
// Build set for FK safety
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const j of jobs) {
const orgUid = orgUids.has(j.organizationUid) ? j.organizationUid : null;
await postgresClient.query(
`INSERT INTO veeam_backup_agent_jobs (instance_uid, original_uid, backup_agent_uid, organization_uid, name, description, config_uid, system_type, backup_policy_uid, backup_policy_name, backup_policy_assign_status, backup_policy_failure_message, status, operation_mode, destination, restore_points, last_run, last_end_time, last_duration, next_run, avg_duration, backup_mode, target_type, is_enabled, schedule_type, failure_message, backed_up_size, free_space, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
original_uid=EXCLUDED.original_uid, backup_agent_uid=EXCLUDED.backup_agent_uid,
organization_uid=EXCLUDED.organization_uid, name=EXCLUDED.name, description=EXCLUDED.description,
config_uid=EXCLUDED.config_uid, system_type=EXCLUDED.system_type,
backup_policy_uid=EXCLUDED.backup_policy_uid, backup_policy_name=EXCLUDED.backup_policy_name,
backup_policy_assign_status=EXCLUDED.backup_policy_assign_status,
backup_policy_failure_message=EXCLUDED.backup_policy_failure_message,
status=EXCLUDED.status, operation_mode=EXCLUDED.operation_mode,
destination=EXCLUDED.destination, restore_points=EXCLUDED.restore_points,
last_run=EXCLUDED.last_run, last_end_time=EXCLUDED.last_end_time,
last_duration=EXCLUDED.last_duration, next_run=EXCLUDED.next_run,
avg_duration=EXCLUDED.avg_duration, backup_mode=EXCLUDED.backup_mode,
target_type=EXCLUDED.target_type, is_enabled=EXCLUDED.is_enabled,
schedule_type=EXCLUDED.schedule_type, failure_message=EXCLUDED.failure_message,
backed_up_size=EXCLUDED.backed_up_size, free_space=EXCLUDED.free_space,
synced_at=NOW(), updated_at=NOW()`,
[
j.instanceUid, j.originalUid, j.backupAgentUid, orgUid,
j.name || j.backupPolicyName || 'Unknown', j.description || null, j.configUid, j.systemType,
j.backupPolicyUid, j.backupPolicyName, j.backupPolicyAssignStatus,
j.backupPolicyFailureMessage || null, j.status, j.operationMode,
j.destination, j.restorePoints, j.lastRun || null, j.lastEndTime || null,
j.lastDuration, j.nextRun || null, j.avgDuration, j.backupMode,
j.targetType, j.isEnabled, j.scheduleType, j.failureMessage || null,
j.backedUpSize, j.freeSpace,
]
);
count++;
}
return count;
}
private async syncProtectedWorkloads(): Promise<number> {
const workloads = await this.client.getProtectedWorkloads();
if (workloads.length === 0) return 0;
// Build sets for FK safety
const knownOrgs = await postgresClient.query('SELECT instance_uid FROM veeam_organizations');
const orgUids = new Set(knownOrgs.rows.map((r: any) => r.instance_uid));
const knownServers = await postgresClient.query('SELECT instance_uid FROM veeam_backup_servers');
const serverUids = new Set(knownServers.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const w of workloads) {
const orgUid = orgUids.has(w.organizationUid) ? w.organizationUid : null;
const serverUid = serverUids.has(w.backupServerUid) ? w.backupServerUid : null;
await postgresClient.query(
`INSERT INTO veeam_protected_workloads (instance_uid, name, backup_server_uid, organization_uid, job_uid, ip_addresses, provisioned_source_size, used_source_size, total_restore_point_size, latest_restore_point_size, restore_points, latest_restore_point_date, malware_state, immutable, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
name=EXCLUDED.name, backup_server_uid=EXCLUDED.backup_server_uid,
organization_uid=EXCLUDED.organization_uid, job_uid=EXCLUDED.job_uid,
ip_addresses=EXCLUDED.ip_addresses, provisioned_source_size=EXCLUDED.provisioned_source_size,
used_source_size=EXCLUDED.used_source_size, total_restore_point_size=EXCLUDED.total_restore_point_size,
latest_restore_point_size=EXCLUDED.latest_restore_point_size, restore_points=EXCLUDED.restore_points,
latest_restore_point_date=EXCLUDED.latest_restore_point_date, malware_state=EXCLUDED.malware_state,
immutable=EXCLUDED.immutable, synced_at=NOW(), updated_at=NOW()`,
[
w.instanceUid, w.name, serverUid, orgUid,
w.jobUid, w.ipAddresses ? JSON.stringify(w.ipAddresses) : null,
w.provisionedSourceSize, w.usedSourceSize, w.totalRestorePointSize,
w.latestRestorePointSize, w.restorePoints, w.latestRestorePointDate || null,
w.malwareState, w.immutable,
]
);
count++;
}
return count;
}
private async syncRepositories(): Promise<number> {
const repos = await this.client.getRepositories();
if (repos.length === 0) return 0;
// Build set for FK safety
const knownServers = await postgresClient.query('SELECT instance_uid FROM veeam_backup_servers');
const serverUids = new Set(knownServers.rows.map((r: any) => r.instance_uid));
let count = 0;
for (const r of repos) {
const serverUid = serverUids.has(r.backupServerUid) ? r.backupServerUid : null;
await postgresClient.query(
`INSERT INTO veeam_repositories (instance_uid, name, backup_server_uid, capacity_bytes, free_space_bytes, used_space_bytes, repository_type, synced_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
ON CONFLICT (instance_uid) DO UPDATE SET
name=EXCLUDED.name, backup_server_uid=EXCLUDED.backup_server_uid,
capacity_bytes=EXCLUDED.capacity_bytes, free_space_bytes=EXCLUDED.free_space_bytes,
used_space_bytes=EXCLUDED.used_space_bytes, repository_type=EXCLUDED.repository_type,
synced_at=NOW(), updated_at=NOW()`,
[
r.instanceUid, r.name, serverUid,
r.capacityBytes || null, r.freeSpaceBytes || null,
r.usedSpaceBytes || null, r.repositoryType || null,
]
);
count++;
}
return count;
}
}

369
lib/types/veeam.ts Normal file
View file

@ -0,0 +1,369 @@
/**
* Veeam Service Provider Console (VSPC) Type Definitions
* Based on VSPC REST API v3 response shapes
*/
// ============================================================================
// VSPC API Response Types (raw API shapes)
// ============================================================================
export interface VspcPagingInfo {
total: number;
count: number;
offset: number;
}
export interface VspcMeta {
pagingInfo: VspcPagingInfo;
}
export interface VspcListResponse<T> {
meta: VspcMeta;
data: T[];
}
export interface VspcSingleResponse<T> {
data: T;
}
// ============================================================================
// VSPC Entity Types (API response shapes)
// ============================================================================
export interface VspcOrganization {
instanceUid: string;
name: string;
alias: string | null;
type: string; // 'Provider' | 'Company'
taxId: string;
email: string | null;
phone: string;
country: number | null;
state: number | null;
countryName: string;
regionName: string;
city: string;
street: string;
locationAdmin0Code: string;
locationAdmin1Code: string;
locationAdmin2Code: string;
notes: string;
zipCode: string;
website: string;
veeamTenantId: string;
companyId: string; // Autotask Company ID (string, needs parseInt)
}
export interface VspcBackupServer {
instanceUid: string;
name: string;
organizationUid: string;
locationUid: string;
managementAgentUid: string;
version: string;
displayVersion: string;
installationUid: string;
backupServerRoleType: string; // 'Client' | 'Hosted' | etc.
status: string; // 'Healthy' | 'Warning' | 'Error'
inHighAvailabilityCluster: boolean;
}
export interface VspcBackupJobSchedule {
startDateTime: string;
startDateTimeUtc: string;
dailyScheduleOptions: unknown | null;
monthlyScheduleOptions: unknown | null;
periodicallyScheduleOptions: unknown | null;
backupWindowOptions: unknown | null;
continuousScheduleEnabled: boolean;
chainingOptions: unknown | null;
}
export interface VspcBackupJob {
instanceUid: string;
uniqueUid: string;
name: string;
description: string;
createdBy: string;
creationTime: string;
backupServerUid: string;
locationUid: string;
siteUid: string;
organizationUid: string;
mappedOrganizationUid: string;
status: string; // 'Success' | 'Warning' | 'Failed' | 'Running' | 'Idle' | 'None'
type: string; // 'BackupVm' | 'SimpleBackupCopy' | 'Replica' | etc.
lastRun: string | null;
lastEndTime: string | null;
lastDuration: number; // seconds
processingRate: number;
avgDuration: number;
transferredData: number; // bytes
backupChainSize: number; // bytes
bottleneck: string; // 'None' | 'Source' | 'Target' | 'Network' | 'Proxy'
isEnabled: boolean;
scheduleType: string; // 'Continuously' | 'Periodically' | 'Daily' | etc.
schedule: VspcBackupJobSchedule;
failureMessage: string | null;
targetType: string; // 'Local' | 'Cloud'
destination: string;
retentionLimit: number;
retentionLimitType: string; // 'Days' | 'RestorePoints'
isGfsOptionEnabled: boolean;
lastSessionTasks: unknown[];
}
export interface VspcBackupAgentJob {
instanceUid: string;
originalUid: string;
backupAgentUid: string;
organizationUid: string;
name: string;
description: string;
configUid: string;
systemType: string; // 'Windows' | 'Linux' | 'Mac'
backupPolicyUid: string;
backupPolicyName: string;
backupPolicyAssignStatus: string; // 'Success' | 'Warning' | 'Failed'
backupPolicyFailureMessage: string | null;
status: string; // 'Success' | 'Warning' | 'Failed' | 'Running' | 'None'
operationMode: string; // 'Workstation' | 'Server'
destination: string;
restorePoints: number;
lastRun: string | null;
lastEndTime: string | null;
lastDuration: number; // seconds
nextRun: string | null;
avgDuration: number;
backupMode: string; // 'File' | 'EntireComputer' | 'Volume'
targetType: string; // 'CloudRepository' | 'Local'
isEnabled: boolean;
scheduleType: string; // 'Daily' | 'Periodically' | etc.
scheduleDisplayName: string;
lastModifiedDate: string | null;
lastModifiedBy: string | null;
failureMessage: string | null;
backedUpSize: number; // bytes
freeSpace: number; // bytes
}
export interface VspcProtectedWorkload {
instanceUid: string;
backupServerUid: string;
organizationUid: string;
name: string;
hierarchyRef: string;
parentHostRef: string;
objectUid: string;
ipAddresses: string[];
provisionedSourceSize: number; // bytes
usedSourceSize: number; // bytes
totalRestorePointSize: number; // bytes
latestRestorePointSize: number; // bytes
restorePoints: number;
latestRestorePointDate: string | null;
jobUid: string;
malwareState: string; // 'Unverified' | 'Clean' | 'Suspicious' | 'Infected'
immutable: boolean;
}
export interface VspcRepository {
instanceUid: string;
name: string;
backupServerUid: string;
capacityBytes?: number;
freeSpaceBytes?: number;
usedSpaceBytes?: number;
repositoryType?: string;
_embedded: unknown | null;
}
// ============================================================================
// Database Entity Types (PostgreSQL row shapes)
// ============================================================================
export interface VeeamOrganization {
instance_uid: string;
name: string;
type: string | null;
company_id: number | null;
tax_id: string | null;
email: string | null;
phone: string | null;
country: string | null;
state: string | null;
city: string | null;
street: string | null;
zip_code: string | null;
website: string | null;
notes: string | null;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamBackupServer {
instance_uid: string;
name: string;
organization_uid: string | null;
version: string | null;
display_version: string | null;
status: string | null;
backup_server_role_type: string | null;
installation_uid: string | null;
location_uid: string | null;
management_agent_uid: string | null;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamBackupJob {
instance_uid: string;
unique_uid: string | null;
name: string;
description: string | null;
backup_server_uid: string | null;
organization_uid: string | null;
status: string | null;
type: string | null;
last_run: Date | null;
last_end_time: Date | null;
last_duration: number | null;
processing_rate: number | null;
avg_duration: number | null;
transferred_data: number | null;
backup_chain_size: number | null;
bottleneck: string | null;
is_enabled: boolean;
schedule_type: string | null;
failure_message: string | null;
target_type: string | null;
destination: string | null;
retention_limit: number | null;
retention_limit_type: string | null;
is_gfs_option_enabled: boolean;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamBackupAgentJob {
instance_uid: string;
original_uid: string | null;
backup_agent_uid: string | null;
organization_uid: string | null;
name: string;
description: string | null;
config_uid: string | null;
system_type: string | null;
backup_policy_uid: string | null;
backup_policy_name: string | null;
backup_policy_assign_status: string | null;
backup_policy_failure_message: string | null;
status: string | null;
operation_mode: string | null;
destination: string | null;
restore_points: number | null;
last_run: Date | null;
last_end_time: Date | null;
last_duration: number | null;
next_run: Date | null;
avg_duration: number | null;
backup_mode: string | null;
target_type: string | null;
is_enabled: boolean;
schedule_type: string | null;
failure_message: string | null;
backed_up_size: number | null;
free_space: number | null;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamProtectedWorkload {
instance_uid: string;
name: string;
backup_server_uid: string | null;
organization_uid: string | null;
job_uid: string | null;
ip_addresses: string | null; // JSON string of IP array
provisioned_source_size: number | null;
used_source_size: number | null;
total_restore_point_size: number | null;
latest_restore_point_size: number | null;
restore_points: number | null;
latest_restore_point_date: Date | null;
malware_state: string | null;
immutable: boolean;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamRepository {
instance_uid: string;
name: string;
backup_server_uid: string | null;
capacity_bytes: number | null;
free_space_bytes: number | null;
used_space_bytes: number | null;
repository_type: string | null;
synced_at: Date;
created_at: Date;
updated_at: Date;
}
export interface VeeamComplianceResult {
id: number;
company_id: number | null;
configuration_item_id: number | null;
veeam_workload_uid: string | null;
mismatch_type: 'contracted_not_backed_up' | 'backed_up_not_contracted';
backup_type_udf: string | null;
device_name: string;
contract_name: string | null;
veeam_workload_name: string | null;
computed_at: Date;
}
// ============================================================================
// UI / Aggregation Types
// ============================================================================
export interface VeeamBackupStatusSummary {
totalProtectedWorkloads: number;
unprotectedWorkloads: number;
successRate24h: number; // percentage 0-100
failedJobs24h: number;
totalRepositoryCapacityBytes: number;
totalRepositoryUsedBytes: number;
repositoryUsagePercent: number;
lastSyncAt: Date | null;
totalBackupServerJobs: number;
totalBackupAgentJobs: number;
}
export interface VeeamCompanyBackupOverview {
companyId: number;
companyName: string;
organizationUid: string;
protectedWorkloadCount: number;
unprotectedWorkloadCount: number;
lastJobStatus: string | null; // 'Success' | 'Warning' | 'Failed'
lastSuccessfulBackup: Date | null;
oldestRestorePointAge: number | null; // hours
backupServerJobCount: number;
backupAgentJobCount: number;
failedJobCount: number;
warningJobCount: number;
}
export interface VeeamComplianceSummary {
totalContractedDevices: number;
matchedDevices: number;
contractedNotBackedUp: number;
backedUpNotContracted: number;
computedAt: Date | null;
}

View file

@ -371,6 +371,16 @@ function mapConfigurationItem(data: any): Record<string, any> {
is_deleted: data.is_deleted || false,
};
// Extract backup-type UDF (ID 29693319) from userDefinedFields
if (data.userDefinedFields && Array.isArray(data.userDefinedFields)) {
const backupUdf = data.userDefinedFields.find(
(udf: any) => udf.name === 'Backup Type' || String(udf.name) === '29693319'
);
if (backupUdf && backupUdf.value) {
mapped.backup_type_udf = backupUdf.value;
}
}
// Add all other fields dynamically
const fieldsToInclude = [
'daily_cost', 'hourly_cost', 'monthly_cost', 'per_use_cost', 'setup_fee',