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:
parent
a1e0e7c7c0
commit
5dc7a7e66b
27 changed files with 3408 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
226
lib/services/veeam-client.ts
Normal file
226
lib/services/veeam-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
lib/services/veeam-compliance-service.ts
Normal file
203
lib/services/veeam-compliance-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
41
lib/services/veeam-factory.ts
Normal file
41
lib/services/veeam-factory.ts
Normal 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;
|
||||
}
|
||||
402
lib/services/veeam-sync-service.ts
Normal file
402
lib/services/veeam-sync-service.ts
Normal 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
369
lib/types/veeam.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue