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

@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
// Total protected workloads
const workloadsResult = await postgresClient.query(
'SELECT COUNT(*) as count FROM veeam_protected_workloads'
);
const totalProtectedWorkloads = parseInt(workloadsResult.rows[0].count);
// Jobs with status counts (last 24h) — combine backup server jobs and agent jobs
const jobStats24h = await postgresClient.query(`
SELECT status, COUNT(*) as count FROM (
SELECT status FROM veeam_backup_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
UNION ALL
SELECT status FROM veeam_backup_agent_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
) combined
GROUP BY status
`);
let successCount = 0;
let failedCount = 0;
let warningCount = 0;
let totalJobs24h = 0;
for (const row of jobStats24h.rows) {
const count = parseInt(row.count);
totalJobs24h += count;
if (row.status === 'Success') successCount += count;
else if (row.status === 'Failed') failedCount += count;
else if (row.status === 'Warning') warningCount += count;
}
const successRate24h = totalJobs24h > 0 ? Math.round((successCount / totalJobs24h) * 1000) / 10 : 100;
// Total job counts
const totalBackupServerJobs = await postgresClient.query(
'SELECT COUNT(*) as count FROM veeam_backup_jobs WHERE is_enabled = true'
);
const totalBackupAgentJobs = await postgresClient.query(
'SELECT COUNT(*) as count FROM veeam_backup_agent_jobs WHERE is_enabled = true'
);
// Last sync time
const lastSync = await postgresClient.query(
"SELECT MAX(synced_at) as last_sync FROM veeam_organizations"
);
return NextResponse.json({
totalProtectedWorkloads,
unprotectedWorkloads: 0, // TODO: compute from config items without matching workloads
successRate24h,
failedJobs24h: failedCount,
warningJobs24h: warningCount,
totalRepositoryCapacityBytes: 0,
totalRepositoryUsedBytes: 0,
repositoryUsagePercent: 0,
lastSyncAt: lastSync.rows[0]?.last_sync || null,
totalBackupServerJobs: parseInt(totalBackupServerJobs.rows[0].count),
totalBackupAgentJobs: parseInt(totalBackupAgentJobs.rows[0].count),
});
} catch (error) {
console.error('[VEEAM-API] backup-status error:', error);
return NextResponse.json({
totalProtectedWorkloads: 0,
unprotectedWorkloads: 0,
successRate24h: 0,
failedJobs24h: 0,
warningJobs24h: 0,
totalRepositoryCapacityBytes: 0,
totalRepositoryUsedBytes: 0,
repositoryUsagePercent: 0,
lastSyncAt: null,
totalBackupServerJobs: 0,
totalBackupAgentJobs: 0,
});
}
}

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ companyId: string }> }
) {
try {
const { companyId } = await params;
const companyIdInt = parseInt(companyId);
// Get backup server jobs
const serverJobs = await postgresClient.query(`
SELECT bj.*, 'server' as job_source
FROM veeam_backup_jobs bj
JOIN veeam_organizations vo ON vo.instance_uid = bj.organization_uid
WHERE vo.company_id = $1
ORDER BY bj.last_run DESC NULLS LAST
`, [companyIdInt]);
// Get backup agent jobs
const agentJobs = await postgresClient.query(`
SELECT aj.*, 'agent' as job_source
FROM veeam_backup_agent_jobs aj
JOIN veeam_organizations vo ON vo.instance_uid = aj.organization_uid
WHERE vo.company_id = $1
ORDER BY aj.last_run DESC NULLS LAST
`, [companyIdInt]);
return NextResponse.json({
serverJobs: serverJobs.rows,
agentJobs: agentJobs.rows,
});
} catch (error) {
console.error('[VEEAM-API] company jobs error:', error);
return NextResponse.json({ serverJobs: [], agentJobs: [] });
}
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ companyId: string }> }
) {
try {
const { companyId } = await params;
const result = await postgresClient.query(`
SELECT pw.*
FROM veeam_protected_workloads pw
JOIN veeam_organizations vo ON vo.instance_uid = pw.organization_uid
WHERE vo.company_id = $1
ORDER BY pw.name
`, [parseInt(companyId)]);
return NextResponse.json(result.rows);
} catch (error) {
console.error('[VEEAM-API] company workloads error:', error);
return NextResponse.json([]);
}
}

View file

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const result = await postgresClient.query(`
SELECT
vo.instance_uid as organization_uid,
vo.company_id,
COALESCE(c.company_name, vo.name) as company_name,
vo.name as organization_name,
(SELECT COUNT(*) FROM veeam_protected_workloads pw WHERE pw.organization_uid = vo.instance_uid) as protected_workload_count,
(SELECT COUNT(*) FROM veeam_backup_jobs bj WHERE bj.organization_uid = vo.instance_uid AND bj.is_enabled = true) as backup_server_job_count,
(SELECT COUNT(*) FROM veeam_backup_agent_jobs aj WHERE aj.organization_uid = vo.instance_uid AND aj.is_enabled = true) as backup_agent_job_count,
(SELECT COUNT(*) FROM veeam_backup_jobs bj WHERE bj.organization_uid = vo.instance_uid AND bj.status = 'Failed' AND bj.is_enabled = true) as failed_server_jobs,
(SELECT COUNT(*) FROM veeam_backup_agent_jobs aj WHERE aj.organization_uid = vo.instance_uid AND aj.status = 'Failed' AND aj.is_enabled = true) as failed_agent_jobs,
(SELECT COUNT(*) FROM veeam_backup_jobs bj WHERE bj.organization_uid = vo.instance_uid AND bj.status = 'Warning' AND bj.is_enabled = true) as warning_server_jobs,
(SELECT COUNT(*) FROM veeam_backup_agent_jobs aj WHERE aj.organization_uid = vo.instance_uid AND aj.status = 'Warning' AND aj.is_enabled = true) as warning_agent_jobs,
(SELECT MAX(pw.latest_restore_point_date) FROM veeam_protected_workloads pw WHERE pw.organization_uid = vo.instance_uid) as last_successful_backup,
(SELECT MIN(pw.latest_restore_point_date) FROM veeam_protected_workloads pw WHERE pw.organization_uid = vo.instance_uid) as oldest_restore_point
FROM veeam_organizations vo
LEFT JOIN companies c ON c.id = vo.company_id
WHERE vo.type = 'Company'
ORDER BY COALESCE(c.company_name, vo.name)
`);
const companies = result.rows.map((row: any) => {
const failedJobs = parseInt(row.failed_server_jobs) + parseInt(row.failed_agent_jobs);
const warningJobs = parseInt(row.warning_server_jobs) + parseInt(row.warning_agent_jobs);
let lastJobStatus = 'Success';
if (failedJobs > 0) lastJobStatus = 'Failed';
else if (warningJobs > 0) lastJobStatus = 'Warning';
return {
organizationUid: row.organization_uid,
companyId: row.company_id,
companyName: row.company_name,
organizationName: row.organization_name,
protectedWorkloadCount: parseInt(row.protected_workload_count),
backupServerJobCount: parseInt(row.backup_server_job_count),
backupAgentJobCount: parseInt(row.backup_agent_job_count),
failedJobCount: failedJobs,
warningJobCount: warningJobs,
lastJobStatus,
lastSuccessfulBackup: row.last_successful_backup,
oldestRestorePoint: row.oldest_restore_point,
};
});
return NextResponse.json(companies);
} catch (error) {
console.error('[VEEAM-API] companies error:', error);
return NextResponse.json([]);
}
}

View file

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ companyId: string }> }
) {
try {
const { companyId } = await params;
const companyIdInt = parseInt(companyId);
const result = await postgresClient.query(`
SELECT
cr.*,
c.company_name
FROM veeam_compliance_results cr
LEFT JOIN companies c ON c.id = cr.company_id
WHERE cr.company_id = $1
ORDER BY cr.mismatch_type, cr.device_name
`, [companyIdInt]);
return NextResponse.json(result.rows);
} catch (error) {
console.error('[VEEAM-API] company compliance error:', error);
return NextResponse.json([]);
}
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
// Get compliance summary
const summaryResult = await postgresClient.query(`
SELECT
mismatch_type,
COUNT(*) as count
FROM veeam_compliance_results
GROUP BY mismatch_type
`);
let contractedNotBackedUp = 0;
let backedUpNotContracted = 0;
for (const row of summaryResult.rows) {
if (row.mismatch_type === 'contracted_not_backed_up') {
contractedNotBackedUp = parseInt(row.count);
} else if (row.mismatch_type === 'backed_up_not_contracted') {
backedUpNotContracted = parseInt(row.count);
}
}
// Get total contracted devices (config items with backup UDF + company has active contract)
const contractedResult = await postgresClient.query(`
SELECT COUNT(DISTINCT ci.id) as count
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
`);
const totalContracted = parseInt(contractedResult.rows[0]?.count || '0');
const matched = totalContracted - contractedNotBackedUp;
// Get last computed time
const lastComputed = await postgresClient.query(
'SELECT MAX(computed_at) as computed_at FROM veeam_compliance_results'
);
// Get mismatch details
const mismatches = await postgresClient.query(`
SELECT
cr.*,
c.company_name
FROM veeam_compliance_results cr
LEFT JOIN companies c ON c.id = cr.company_id
ORDER BY cr.mismatch_type, c.company_name, cr.device_name
`);
return NextResponse.json({
summary: {
totalContractedDevices: totalContracted,
matchedDevices: Math.max(0, matched),
contractedNotBackedUp,
backedUpNotContracted,
computedAt: lastComputed.rows[0]?.computed_at || null,
},
mismatches: mismatches.rows,
});
} catch (error) {
console.error('[VEEAM-API] compliance error:', error);
return NextResponse.json({
summary: {
totalContractedDevices: 0,
matchedDevices: 0,
contractedNotBackedUp: 0,
backedUpNotContracted: 0,
computedAt: null,
},
mismatches: [],
});
}
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
try {
const result = await postgresClient.query(`
SELECT
r.instance_uid,
r.name,
r.backup_server_uid,
bs.name as backup_server_name,
r.capacity_bytes,
r.free_space_bytes,
r.used_space_bytes,
r.repository_type,
r.synced_at
FROM veeam_repositories r
LEFT JOIN veeam_backup_servers bs ON bs.instance_uid = r.backup_server_uid
ORDER BY r.name
`);
return NextResponse.json(result.rows);
} catch (error) {
console.error('[VEEAM-API] repositories error:', error);
return NextResponse.json([]);
}
}

View file

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { VeeamSyncService } from '@/lib/services/veeam-sync-service';
let syncServiceInstance: VeeamSyncService | null = null;
function getSyncService(): VeeamSyncService {
if (!syncServiceInstance) {
syncServiceInstance = new VeeamSyncService();
}
return syncServiceInstance;
}
export async function POST(request: NextRequest) {
try {
const syncService = getSyncService();
if (syncService.isSyncInProgress()) {
return NextResponse.json(
{ error: 'A Veeam sync is already in progress' },
{ status: 409 }
);
}
const body = await request.json().catch(() => ({}));
const syncType = body.syncType === 'full' ? 'full' : 'incremental';
// Run sync in background, return immediately
const resultPromise = syncType === 'full'
? syncService.fullSync('manual')
: syncService.incrementalSync('manual');
resultPromise.catch((err) => {
console.error('[VEEAM-SYNC-API] Background sync failed:', err);
});
return NextResponse.json({
message: `Veeam ${syncType} sync started`,
syncType,
});
} catch (error) {
console.error('[VEEAM-SYNC-API] Error:', error);
return NextResponse.json(
{ error: 'Failed to start Veeam sync' },
{ status: 500 }
);
}
}
export async function GET() {
try {
const syncService = getSyncService();
return NextResponse.json({
isSyncing: syncService.isSyncInProgress(),
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get sync status' },
{ status: 500 }
);
}
}

203
app/backup-status/page.tsx Normal file
View file

@ -0,0 +1,203 @@
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/app-navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackupSummaryCards } from '@/components/backup/backup-summary-cards';
import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/company-backup-table';
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table';
import { RefreshCw, AlertTriangle } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
interface BackupStatusData {
totalProtectedWorkloads: number;
unprotectedWorkloads: number;
successRate24h: number;
failedJobs24h: number;
warningJobs24h: number;
totalBackupServerJobs: number;
totalBackupAgentJobs: number;
lastSyncAt: string | null;
}
interface ComplianceData {
summary: {
totalContractedDevices: number;
matchedDevices: number;
contractedNotBackedUp: number;
backedUpNotContracted: number;
computedAt: string | null;
};
mismatches: any[];
}
function timeAgo(dateStr: string | null): string {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
export default function BackupStatusPage() {
const [status, setStatus] = useState<BackupStatusData | null>(null);
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const fetchData = async () => {
try {
const [statusRes, companiesRes, complianceRes] = await Promise.all([
fetch('/api/veeam/backup-status').then(r => r.json()),
fetch('/api/veeam/companies').then(r => r.json()),
fetch('/api/veeam/compliance').then(r => r.json()),
]);
setStatus(statusRes);
setCompanies(Array.isArray(companiesRes) ? companiesRes : []);
setCompliance(complianceRes);
} catch (error) {
console.error('Failed to fetch backup status:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/veeam/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncType: 'full' }),
});
// Poll for completion
const poll = setInterval(async () => {
const res = await fetch('/api/veeam/sync').then(r => r.json());
if (!res.isSyncing) {
clearInterval(poll);
setSyncing(false);
fetchData();
}
}, 3000);
// Safety timeout
setTimeout(() => {
clearInterval(poll);
setSyncing(false);
fetchData();
}, 120000);
} catch {
setSyncing(false);
}
};
const isSyncStale = status?.lastSyncAt
? Date.now() - new Date(status.lastSyncAt).getTime() > 2 * 60 * 60 * 1000
: true;
if (loading) {
return (
<div>
<PageHeader title="Backup Status" description="Veeam backup health and compliance overview" />
<div className="container py-6 space-y-6">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
<Skeleton className="h-96" />
</div>
</div>
);
}
return (
<div>
<PageHeader
title="Backup Status"
description="Veeam backup health and compliance overview"
actions={
<div className="flex items-center gap-3">
{status?.lastSyncAt && (
<span className="text-sm text-muted-foreground">
Last sync: {timeAgo(status.lastSyncAt)}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={syncing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing...' : 'Sync Now'}
</Button>
</div>
}
/>
<div className="container py-6 space-y-6">
{/* Stale sync warning */}
{isSyncStale && !loading && (
<div className="flex items-center gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">
Backup data may be outdated. Last sync: {status?.lastSyncAt ? timeAgo(status.lastSyncAt) : 'Never'}.
</span>
</div>
)}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Backup Overview</TabsTrigger>
<TabsTrigger value="compliance">
Contract Compliance
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
<Badge variant="destructive" className="ml-2 h-5 px-1.5 text-xs">
{compliance.summary.contractedNotBackedUp + compliance.summary.backedUpNotContracted}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{status && (
<BackupSummaryCards
totalProtectedWorkloads={status.totalProtectedWorkloads}
successRate24h={status.successRate24h}
failedJobs24h={status.failedJobs24h}
warningJobs24h={status.warningJobs24h}
totalBackupServerJobs={status.totalBackupServerJobs}
totalBackupAgentJobs={status.totalBackupAgentJobs}
lastSyncAt={status.lastSyncAt}
/>
)}
<CompanyBackupTable companies={companies} />
</TabsContent>
<TabsContent value="compliance" className="space-y-6">
{compliance && (
<>
<ComplianceSummaryCards
totalContractedDevices={compliance.summary.totalContractedDevices}
matchedDevices={compliance.summary.matchedDevices}
contractedNotBackedUp={compliance.summary.contractedNotBackedUp}
backedUpNotContracted={compliance.summary.backedUpNotContracted}
/>
<ComplianceDetailTable mismatches={compliance.mismatches} />
</>
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { HardDrive, ShieldAlert, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface BackupSummaryCardsProps {
totalProtectedWorkloads: number;
successRate24h: number;
failedJobs24h: number;
warningJobs24h: number;
totalBackupServerJobs: number;
totalBackupAgentJobs: number;
lastSyncAt: string | null;
}
export function BackupSummaryCards({
totalProtectedWorkloads,
successRate24h,
failedJobs24h,
warningJobs24h,
totalBackupServerJobs,
totalBackupAgentJobs,
lastSyncAt,
}: BackupSummaryCardsProps) {
const cards = [
{
title: 'Protected Workloads',
value: totalProtectedWorkloads,
icon: HardDrive,
color: 'text-blue-500',
},
{
title: 'Total Jobs',
value: totalBackupServerJobs + totalBackupAgentJobs,
icon: CheckCircle,
color: 'text-green-500',
subtitle: `${totalBackupServerJobs} server / ${totalBackupAgentJobs} agent`,
},
{
title: '24h Success Rate',
value: `${successRate24h}%`,
icon: successRate24h >= 95 ? CheckCircle : AlertTriangle,
color: successRate24h >= 95 ? 'text-green-500' : successRate24h >= 80 ? 'text-yellow-500' : 'text-red-500',
},
{
title: 'Failed Jobs (24h)',
value: failedJobs24h,
icon: XCircle,
color: failedJobs24h > 0 ? 'text-red-500' : 'text-green-500',
},
{
title: 'Warnings (24h)',
value: warningJobs24h,
icon: AlertTriangle,
color: warningJobs24h > 0 ? 'text-yellow-500' : 'text-green-500',
},
];
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
{card.subtitle && (
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p>
)}
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -0,0 +1,209 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface CompanyBackupDetailProps {
companyId: number | null;
companyName: string;
}
function StatusBadge({ status }: { status: string }) {
const colors = status === 'Success'
? 'bg-green-500/10 text-green-500'
: status === 'Warning'
? 'bg-yellow-500/10 text-yellow-500'
: status === 'Failed'
? 'bg-red-500/10 text-red-500'
: status === 'Running'
? 'bg-blue-500/10 text-blue-500'
: 'bg-gray-500/10 text-gray-500';
return <Badge className={colors}>{status}</Badge>;
}
function formatBytes(bytes: number | null): string {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let val = bytes;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
}
export function CompanyBackupDetail({ companyId, companyName }: CompanyBackupDetailProps) {
const [workloads, setWorkloads] = useState<any[]>([]);
const [jobs, setJobs] = useState<{ serverJobs: any[]; agentJobs: any[] }>({ serverJobs: [], agentJobs: [] });
const [compliance, setCompliance] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!companyId) {
setLoading(false);
return;
}
Promise.all([
fetch(`/api/veeam/companies/${companyId}/workloads`).then(r => r.json()),
fetch(`/api/veeam/companies/${companyId}/jobs`).then(r => r.json()),
fetch(`/api/veeam/compliance/${companyId}`).then(r => r.json()),
]).then(([w, j, c]) => {
setWorkloads(Array.isArray(w) ? w : []);
setJobs(j || { serverJobs: [], agentJobs: [] });
setCompliance(Array.isArray(c) ? c : []);
setLoading(false);
}).catch(() => setLoading(false));
}, [companyId]);
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
if (!companyId) {
return <p className="text-muted-foreground text-sm">No Autotask company linked to this organization.</p>;
}
return (
<div className="space-y-6">
{/* Protected Workloads */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Protected Workloads ({workloads.length})</CardTitle>
</CardHeader>
<CardContent>
{workloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No protected workloads</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Restore Points</TableHead>
<TableHead>Latest Restore</TableHead>
<TableHead>Size</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workloads.map((w: any) => (
<TableRow key={w.instance_uid}>
<TableCell className="font-medium">{w.name}</TableCell>
<TableCell>{w.restore_points ?? '-'}</TableCell>
<TableCell className="text-sm">{formatDate(w.latest_restore_point_date)}</TableCell>
<TableCell className="text-sm">{formatBytes(w.used_source_size)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Backup Jobs */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">
Backup Jobs ({jobs.serverJobs.length} server, {jobs.agentJobs.length} agent)
</CardTitle>
</CardHeader>
<CardContent>
{jobs.serverJobs.length === 0 && jobs.agentJobs.length === 0 ? (
<p className="text-sm text-muted-foreground">No backup jobs</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.serverJobs.map((j: any) => (
<TableRow key={j.instance_uid}>
<TableCell className="font-medium">{j.name}</TableCell>
<TableCell className="text-sm">{j.type || 'Server'}</TableCell>
<TableCell><StatusBadge status={j.status || 'Unknown'} /></TableCell>
<TableCell className="text-sm">{formatDate(j.last_run)}</TableCell>
<TableCell className="text-sm">{j.last_duration ? `${Math.round(j.last_duration / 60)}m` : '-'}</TableCell>
</TableRow>
))}
{jobs.agentJobs.map((j: any) => (
<TableRow key={j.instance_uid}>
<TableCell className="font-medium">{j.name}</TableCell>
<TableCell className="text-sm">{j.backup_mode || 'Agent'}</TableCell>
<TableCell><StatusBadge status={j.status || 'Unknown'} /></TableCell>
<TableCell className="text-sm">{formatDate(j.last_run)}</TableCell>
<TableCell className="text-sm">{j.last_duration ? `${Math.round(j.last_duration / 60)}m` : '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Compliance Mismatches */}
{compliance.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-yellow-500">
Compliance Issues ({compliance.length})
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>Issue</TableHead>
<TableHead>Backup UDF</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compliance.map((c: any) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.device_name}</TableCell>
<TableCell>
<Badge className={
c.mismatch_type === 'contracted_not_backed_up'
? 'bg-red-500/10 text-red-500'
: 'bg-yellow-500/10 text-yellow-500'
}>
{c.mismatch_type === 'contracted_not_backed_up' ? 'Missing Backup' : 'No Contract'}
</Badge>
</TableCell>
<TableCell className="text-sm">{c.backup_type_udf || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,160 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ChevronDown, ChevronRight, Search } from 'lucide-react';
import { CompanyBackupDetail } from './company-backup-detail';
export interface CompanyBackupRow {
organizationUid: string;
companyId: number | null;
companyName: string;
organizationName: string;
protectedWorkloadCount: number;
backupServerJobCount: number;
backupAgentJobCount: number;
failedJobCount: number;
warningJobCount: number;
lastJobStatus: string;
lastSuccessfulBackup: string | null;
oldestRestorePoint: string | null;
}
interface CompanyBackupTableProps {
companies: CompanyBackupRow[];
}
function StatusBadge({ status }: { status: string }) {
const variant = status === 'Success' ? 'default' : status === 'Warning' ? 'secondary' : 'destructive';
const colors = status === 'Success'
? 'bg-green-500/10 text-green-500 hover:bg-green-500/20'
: status === 'Warning'
? 'bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20'
: 'bg-red-500/10 text-red-500 hover:bg-red-500/20';
return <Badge className={colors}>{status}</Badge>;
}
function timeAgo(dateStr: string | null): string {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const hours = Math.floor(diff / 3600000);
if (hours < 1) return 'Just now';
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CompanyBackupTable({ companies }: CompanyBackupTableProps) {
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const filtered = companies.filter((c) => {
const matchesSearch = c.companyName.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === 'all' || c.lastJobStatus === statusFilter;
return matchesSearch && matchesStatus;
});
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search companies..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2">
{['all', 'Success', 'Warning', 'Failed'].map((s) => (
<Button
key={s}
variant={statusFilter === s ? 'default' : 'outline'}
size="sm"
onClick={() => setStatusFilter(s)}
>
{s === 'all' ? 'All' : s}
</Button>
))}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8"></TableHead>
<TableHead>Company</TableHead>
<TableHead className="text-center">Protected</TableHead>
<TableHead className="text-center">Server Jobs</TableHead>
<TableHead className="text-center">Agent Jobs</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-right">Last Backup</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No companies found
</TableCell>
</TableRow>
) : (
filtered.map((company) => (
<>
<TableRow
key={company.organizationUid}
className="cursor-pointer hover:bg-muted/50"
onClick={() =>
setExpandedRow(
expandedRow === company.organizationUid ? null : company.organizationUid
)
}
>
<TableCell>
{expandedRow === company.organizationUid ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</TableCell>
<TableCell className="font-medium">{company.companyName}</TableCell>
<TableCell className="text-center">{company.protectedWorkloadCount}</TableCell>
<TableCell className="text-center">{company.backupServerJobCount}</TableCell>
<TableCell className="text-center">{company.backupAgentJobCount}</TableCell>
<TableCell className="text-center">
<StatusBadge status={company.lastJobStatus} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-sm">
{timeAgo(company.lastSuccessfulBackup)}
</TableCell>
</TableRow>
{expandedRow === company.organizationUid && (
<TableRow key={`${company.organizationUid}-detail`}>
<TableCell colSpan={7} className="bg-muted/30 p-4">
<CompanyBackupDetail companyId={company.companyId} companyName={company.companyName} />
</TableCell>
</TableRow>
)}
</>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search } from 'lucide-react';
interface ComplianceMismatch {
id: number;
company_id: number | null;
company_name: string | null;
configuration_item_id: number | null;
veeam_workload_uid: string | null;
mismatch_type: string;
backup_type_udf: string | null;
device_name: string;
contract_name: string | null;
veeam_workload_name: string | null;
}
interface ComplianceDetailTableProps {
mismatches: ComplianceMismatch[];
}
export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const filtered = mismatches.filter((m) => {
const matchesSearch =
(m.device_name || '').toLowerCase().includes(search.toLowerCase()) ||
(m.company_name || '').toLowerCase().includes(search.toLowerCase());
const matchesType =
typeFilter === 'all' || m.mismatch_type === typeFilter;
return matchesSearch && matchesType;
});
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search devices or companies..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2">
{[
{ key: 'all', label: 'All' },
{ key: 'contracted_not_backed_up', label: 'Missing Backup' },
{ key: 'backed_up_not_contracted', label: 'No Contract' },
].map((f) => (
<Button
key={f.key}
variant={typeFilter === f.key ? 'default' : 'outline'}
size="sm"
onClick={() => setTypeFilter(f.key)}
>
{f.label}
</Button>
))}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Device</TableHead>
<TableHead>Issue</TableHead>
<TableHead>Backup UDF</TableHead>
<TableHead>Contract</TableHead>
<TableHead>Veeam Workload</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
{mismatches.length === 0
? 'No compliance issues found — all clear!'
: 'No matching results'}
</TableCell>
</TableRow>
) : (
filtered.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.company_name || '-'}</TableCell>
<TableCell>{m.device_name}</TableCell>
<TableCell>
<Badge
className={
m.mismatch_type === 'contracted_not_backed_up'
? 'bg-red-500/10 text-red-500'
: 'bg-yellow-500/10 text-yellow-500'
}
>
{m.mismatch_type === 'contracted_not_backed_up'
? 'Missing Backup'
: 'No Contract'}
</Badge>
</TableCell>
<TableCell className="text-sm">{m.backup_type_udf || '-'}</TableCell>
<TableCell className="text-sm">{m.contract_name || '-'}</TableCell>
<TableCell className="text-sm">{m.veeam_workload_name || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FileCheck, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface ComplianceSummaryCardsProps {
totalContractedDevices: number;
matchedDevices: number;
contractedNotBackedUp: number;
backedUpNotContracted: number;
}
export function ComplianceSummaryCards({
totalContractedDevices,
matchedDevices,
contractedNotBackedUp,
backedUpNotContracted,
}: ComplianceSummaryCardsProps) {
const cards = [
{
title: 'Contracted Backups',
value: totalContractedDevices,
icon: FileCheck,
color: 'text-blue-500',
},
{
title: 'Matched',
value: matchedDevices,
icon: CheckCircle,
color: 'text-green-500',
},
{
title: 'Missing Backup',
value: contractedNotBackedUp,
icon: XCircle,
color: contractedNotBackedUp > 0 ? 'text-red-500' : 'text-green-500',
subtitle: 'Contracted but not in Veeam',
},
{
title: 'No Contract',
value: backedUpNotContracted,
icon: AlertTriangle,
color: backedUpNotContracted > 0 ? 'text-yellow-500' : 'text-green-500',
subtitle: 'In Veeam but no contract',
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
{card.subtitle && (
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p>
)}
</CardContent>
</Card>
))}
</div>
);
}

View file

@ -12,7 +12,8 @@ import {
Database,
RefreshCw,
ChevronDown,
Activity
Activity,
HardDrive
} from 'lucide-react';
import {
NavigationMenu,
@ -47,6 +48,12 @@ const navigationItems: NavItem[] = [
icon: Server,
description: 'Manage IT assets and devices'
},
{
title: 'Backup Status',
href: '/backup-status',
icon: HardDrive,
description: 'Veeam backup health and compliance'
},
{
title: 'Admin',
icon: Activity,

View file

@ -81,6 +81,10 @@ services:
SALESBLDR_API_URL: ${SALESBLDR_API_URL}
SALESBLDR_API_KEY: ${SALESBLDR_API_KEY}
# Veeam VSPC Configuration
VEEAM_VSPC_URL: ${VEEAM_VSPC_URL}
VEEAM_VSPC_API_KEY: ${VEEAM_VSPC_API_KEY}
# PostgreSQL Configuration
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432

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',

View file

@ -0,0 +1,201 @@
-- ============================================================================
-- Veeam Service Provider Console (VSPC) Integration Tables
-- Creates tables for synced Veeam backup data and compliance results
-- ============================================================================
-- Veeam Organizations (VSPC tenants/companies)
CREATE TABLE IF NOT EXISTS veeam_organizations (
instance_uid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(50),
company_id INTEGER,
tax_id VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
country VARCHAR(255),
state VARCHAR(255),
city VARCHAR(255),
street VARCHAR(500),
zip_code VARCHAR(50),
website VARCHAR(255),
notes TEXT,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Backup Servers
CREATE TABLE IF NOT EXISTS veeam_backup_servers (
instance_uid VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
version VARCHAR(100),
display_version VARCHAR(100),
status VARCHAR(50),
backup_server_role_type VARCHAR(50),
installation_uid VARCHAR(255),
location_uid VARCHAR(255),
management_agent_uid VARCHAR(255),
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Backup Server Jobs (VM-level backup jobs)
CREATE TABLE IF NOT EXISTS veeam_backup_jobs (
instance_uid VARCHAR(255) PRIMARY KEY,
unique_uid VARCHAR(255),
name VARCHAR(500) NOT NULL,
description TEXT,
backup_server_uid VARCHAR(255) REFERENCES veeam_backup_servers(instance_uid) ON DELETE SET NULL,
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
status VARCHAR(50),
type VARCHAR(100),
last_run TIMESTAMP WITH TIME ZONE,
last_end_time TIMESTAMP WITH TIME ZONE,
last_duration INTEGER,
processing_rate DOUBLE PRECISION,
avg_duration INTEGER,
transferred_data BIGINT,
backup_chain_size BIGINT,
bottleneck VARCHAR(100),
is_enabled BOOLEAN DEFAULT true,
schedule_type VARCHAR(100),
failure_message TEXT,
target_type VARCHAR(100),
destination VARCHAR(500),
retention_limit INTEGER,
retention_limit_type VARCHAR(50),
is_gfs_option_enabled BOOLEAN DEFAULT false,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Backup Agent Jobs (workstation/physical server backup jobs)
CREATE TABLE IF NOT EXISTS veeam_backup_agent_jobs (
instance_uid VARCHAR(255) PRIMARY KEY,
original_uid VARCHAR(255),
backup_agent_uid VARCHAR(255),
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
name VARCHAR(500) NOT NULL,
description TEXT,
config_uid VARCHAR(255),
system_type VARCHAR(50),
backup_policy_uid VARCHAR(255),
backup_policy_name VARCHAR(500),
backup_policy_assign_status VARCHAR(50),
backup_policy_failure_message TEXT,
status VARCHAR(50),
operation_mode VARCHAR(50),
destination VARCHAR(500),
restore_points INTEGER,
last_run TIMESTAMP WITH TIME ZONE,
last_end_time TIMESTAMP WITH TIME ZONE,
last_duration INTEGER,
next_run TIMESTAMP WITH TIME ZONE,
avg_duration INTEGER,
backup_mode VARCHAR(50),
target_type VARCHAR(100),
is_enabled BOOLEAN DEFAULT true,
schedule_type VARCHAR(100),
failure_message TEXT,
backed_up_size BIGINT,
free_space BIGINT,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Protected Workloads (VMs)
CREATE TABLE IF NOT EXISTS veeam_protected_workloads (
instance_uid VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
backup_server_uid VARCHAR(255) REFERENCES veeam_backup_servers(instance_uid) ON DELETE SET NULL,
organization_uid VARCHAR(255) REFERENCES veeam_organizations(instance_uid) ON DELETE SET NULL,
job_uid VARCHAR(255),
ip_addresses TEXT,
provisioned_source_size BIGINT,
used_source_size BIGINT,
total_restore_point_size BIGINT,
latest_restore_point_size BIGINT,
restore_points INTEGER,
latest_restore_point_date TIMESTAMP WITH TIME ZONE,
malware_state VARCHAR(50),
immutable BOOLEAN DEFAULT false,
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Repositories
CREATE TABLE IF NOT EXISTS veeam_repositories (
instance_uid VARCHAR(255) PRIMARY KEY,
name VARCHAR(500) NOT NULL,
backup_server_uid VARCHAR(255) REFERENCES veeam_backup_servers(instance_uid) ON DELETE SET NULL,
capacity_bytes BIGINT,
free_space_bytes BIGINT,
used_space_bytes BIGINT,
repository_type VARCHAR(100),
synced_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Veeam Compliance Results (computed after each sync)
CREATE TABLE IF NOT EXISTS veeam_compliance_results (
id SERIAL PRIMARY KEY,
company_id INTEGER,
configuration_item_id INTEGER,
veeam_workload_uid VARCHAR(255),
mismatch_type VARCHAR(50) NOT NULL,
backup_type_udf VARCHAR(255),
device_name VARCHAR(500) NOT NULL,
contract_name VARCHAR(500),
veeam_workload_name VARCHAR(500),
computed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- INDEXES
-- ============================================================================
-- veeam_organizations indexes
CREATE INDEX IF NOT EXISTS idx_veeam_organizations_company_id ON veeam_organizations(company_id);
CREATE INDEX IF NOT EXISTS idx_veeam_organizations_type ON veeam_organizations(type);
CREATE INDEX IF NOT EXISTS idx_veeam_organizations_synced_at ON veeam_organizations(synced_at);
-- veeam_backup_servers indexes
CREATE INDEX IF NOT EXISTS idx_veeam_backup_servers_organization_uid ON veeam_backup_servers(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_servers_status ON veeam_backup_servers(status);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_servers_synced_at ON veeam_backup_servers(synced_at);
-- veeam_backup_jobs indexes
CREATE INDEX IF NOT EXISTS idx_veeam_backup_jobs_organization_uid ON veeam_backup_jobs(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_jobs_backup_server_uid ON veeam_backup_jobs(backup_server_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_jobs_status ON veeam_backup_jobs(status);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_jobs_last_run ON veeam_backup_jobs(last_run);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_jobs_synced_at ON veeam_backup_jobs(synced_at);
-- veeam_backup_agent_jobs indexes
CREATE INDEX IF NOT EXISTS idx_veeam_backup_agent_jobs_organization_uid ON veeam_backup_agent_jobs(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_agent_jobs_status ON veeam_backup_agent_jobs(status);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_agent_jobs_last_run ON veeam_backup_agent_jobs(last_run);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_agent_jobs_synced_at ON veeam_backup_agent_jobs(synced_at);
CREATE INDEX IF NOT EXISTS idx_veeam_backup_agent_jobs_backup_policy_name ON veeam_backup_agent_jobs(backup_policy_name);
-- veeam_protected_workloads indexes
CREATE INDEX IF NOT EXISTS idx_veeam_protected_workloads_organization_uid ON veeam_protected_workloads(organization_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_protected_workloads_backup_server_uid ON veeam_protected_workloads(backup_server_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_protected_workloads_job_uid ON veeam_protected_workloads(job_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_protected_workloads_latest_restore_point ON veeam_protected_workloads(latest_restore_point_date);
CREATE INDEX IF NOT EXISTS idx_veeam_protected_workloads_synced_at ON veeam_protected_workloads(synced_at);
-- veeam_repositories indexes
CREATE INDEX IF NOT EXISTS idx_veeam_repositories_backup_server_uid ON veeam_repositories(backup_server_uid);
CREATE INDEX IF NOT EXISTS idx_veeam_repositories_synced_at ON veeam_repositories(synced_at);
-- veeam_compliance_results indexes
CREATE INDEX IF NOT EXISTS idx_veeam_compliance_results_company_id ON veeam_compliance_results(company_id);
CREATE INDEX IF NOT EXISTS idx_veeam_compliance_results_mismatch_type ON veeam_compliance_results(mismatch_type);
CREATE INDEX IF NOT EXISTS idx_veeam_compliance_results_computed_at ON veeam_compliance_results(computed_at);

View file

@ -0,0 +1,12 @@
-- ============================================================================
-- Add backup_type_udf column to configuration_items table
-- Stores the Autotask UDF value indicating backup type (e.g., Server Image,
-- Workstation Image, Workstation File Based) for contract compliance analysis
-- ============================================================================
ALTER TABLE configuration_items
ADD COLUMN IF NOT EXISTS backup_type_udf VARCHAR(255);
-- Index for efficient compliance queries
CREATE INDEX IF NOT EXISTS idx_configuration_items_backup_type_udf
ON configuration_items(backup_type_udf);

View file

@ -0,0 +1,566 @@
# PRD: Veeam Service Provider Console (VSPC) Backup Integration
## Introduction/Overview
This feature adds Veeam Service Provider Console (VSPC) as a connected vendor in Pulse, providing visibility into backup job status, protected workloads, backup repositories, and restore point data across all managed clients. Data will be synced on a schedule to PostgreSQL (following the same pattern as the Autotask sync) and displayed on a dedicated **Backup Status** page.
Additionally, this integration includes a **Contract Compliance** audit that cross-references Autotask configuration items (which have a backup-type UDF and are associated with active contracts) against actual Veeam backup data. This ensures every contracted backup is actually running, and every active Veeam backup is covered by a contract.
**Problem Statement:** MSP technicians and administrators currently have no visibility into backup health within Pulse. They must log into the Veeam Service Provider Console separately to check backup job statuses, identify failed backups, verify protected machines, and assess repository capacity. This context-switching slows response times and makes it easy to miss backup failures. Furthermore, there is no automated way to verify that devices under an active backup contract are actually being backed up, or that Veeam is not backing up devices that aren't under contract — leading to revenue leakage and compliance gaps.
**Goal:** Provide a centralized Backup Status page within Pulse that surfaces all critical Veeam backup data — job statuses, protected/unprotected machines, repository usage, restore point history, and RPO/SLA compliance — synced on a configurable schedule and linked to Autotask companies via the Company ID field in VSPC. Additionally, provide a Contract Compliance view that flags mismatches between contracted backup services and actual Veeam protection.
## Goals
1. **Visibility Goal:** Surface backup job status (success/failure/warning) for all managed clients in a single page
2. **Coverage Goal:** Identify protected vs unprotected machines per company to highlight backup gaps
3. **Capacity Goal:** Display backup repository usage and capacity to enable proactive storage management
4. **Compliance Goal:** Show RPO/SLA compliance status per company and per protected workload
5. **Contract Compliance Goal:** Cross-reference Autotask config items with backup UDFs on active contracts against Veeam protected workloads to identify: (a) contracted devices not being backed up, and (b) devices being backed up without a contract
6. **Sync Goal:** Reliably sync Veeam data to PostgreSQL on a configurable schedule, following the existing Autotask sync pattern
7. **Matching Goal:** Automatically associate VSPC organizations with Autotask companies using the Company ID field stored in VSPC
8. **UDF Sync Goal:** Sync the backup-type UDF (ID: `29693319`) from Autotask configuration items to PostgreSQL to enable contract compliance analysis
## User Stories
1. **As an MSP administrator**, I want to see a summary of all backup job statuses across all clients on a single page, so that I can quickly identify which clients have failing backups without logging into VSPC.
2. **As an MSP technician**, I want to view detailed backup information for a specific company — including last backup time, job status, and protected machines — so that I can troubleshoot backup issues efficiently.
3. **As an MSP administrator**, I want to see which machines are not protected by any backup job, so that I can ensure complete backup coverage for all clients.
4. **As an MSP administrator**, I want to monitor backup repository usage and capacity, so that I can proactively provision additional storage before repositories fill up.
5. **As an MSP administrator**, I want to see restore point history for protected machines, so that I can verify data recoverability and RPO compliance.
6. **As an MSP administrator**, I want backup data to sync automatically on a schedule, so that the Backup Status page always shows recent data without manual intervention.
7. **As an MSP administrator**, I want to configure the Veeam sync schedule from the admin panel, so that I can control how frequently data is refreshed.
8. **As an MSP administrator**, I want to see which configuration items have a backup UDF set (Server Image, Workstation Image, Workstation File Based, etc.) and are on an active contract but are NOT being backed up in Veeam, so that I can identify contracted services that aren't being delivered.
9. **As an MSP administrator**, I want to see which machines are being backed up in Veeam but do NOT have a corresponding configuration item with a backup UDF on an active contract, so that I can identify unbilled backup services and potential revenue leakage.
10. **As an MSP administrator**, I want a summary card showing the total number of contract compliance mismatches, so that I can quickly assess the overall health of backup contract alignment.
## Functional Requirements
### FR1: Veeam VSPC API Client
1.1. Create a Veeam VSPC API client service (`/lib/services/veeam-client.ts`) that handles authentication and API requests
1.2. Use credentials from environment variables: `VEEAM_VSPC_URL`, `VEEAM_VSPC_API_KEY`
1.3. Implement API Key-based authorization using the `Authorization: Bearer <API-Key>` header
1.4. Base URL format: `https://<hostname>:1280/api/v3`
1.5. Support pagination using `offset` and `limit` query parameters
1.6. Support filtering using the VSPC `filter` query parameter syntax
1.7. Include proper error handling and logging for all API calls
1.8. Implement rate limiting to respect VSPC throttling settings
1.9. Handle token refresh if using OAuth 2.0 as a fallback authentication method
### FR2: VSPC Data Fetching
Fetch the following data from the VSPC REST API v3:
2.1. **Organizations**`/organizations` — Company/tenant data including `instanceUid`, `name`, `companyId` (maps to Autotask Company ID)
2.2. **Backup Servers**`/infrastructure/backupServers` — Server name, version, status, role type
2.3. **Backup Jobs** — Backup job definitions including name, type, schedule, target repository, associated organization
2.4. **Backup Job Sessions** — Recent job session results including status (Success/Warning/Failed), start time, end time, duration, transferred data size, bottleneck info
2.5. **Protected Workloads** — Protected virtual machines and physical servers including name, platform, protection status, last restore point date
2.6. **Backup Repositories** — Repository name, capacity, free space, used space, associated backup server
2.7. **Restore Points** — Restore point data per protected workload including creation date, size, type (full/incremental)
### FR3: Company Matching
3.1. Match VSPC organizations to Autotask companies using the `companyId` field in VSPC, which contains the Autotask Company ID
3.2. Store the mapping in the `veeam_organizations` database table
3.3. Handle cases where `companyId` is not set in VSPC (log warning, skip matching)
3.4. Support manual mapping override via admin UI for edge cases
### FR4: PostgreSQL Database Schema
Create the following tables for synced Veeam data:
4.1. **`veeam_organizations`** — VSPC organization data
- `instance_uid` (VARCHAR, PK) — VSPC organization UID
- `name` (VARCHAR) — Organization name
- `company_id` (INTEGER) — Autotask Company ID (FK reference)
- `status` (VARCHAR) — Organization status
- `synced_at` (TIMESTAMP)
4.2. **`veeam_backup_servers`** — Backup server infrastructure
- `instance_uid` (VARCHAR, PK)
- `name` (VARCHAR)
- `organization_uid` (VARCHAR, FK → veeam_organizations)
- `version` (VARCHAR)
- `display_version` (VARCHAR)
- `status` (VARCHAR) — Healthy/Warning/Error
- `role_type` (VARCHAR) — CloudConnect/Hosted/etc.
- `synced_at` (TIMESTAMP)
4.3. **`veeam_backup_jobs`** — Backup job definitions
- `instance_uid` (VARCHAR, PK)
- `name` (VARCHAR)
- `organization_uid` (VARCHAR, FK → veeam_organizations)
- `backup_server_uid` (VARCHAR, FK → veeam_backup_servers)
- `job_type` (VARCHAR) — Backup/Replication/Copy/etc.
- `status` (VARCHAR) — Running/Idle/Disabled
- `last_run` (TIMESTAMP)
- `last_result` (VARCHAR) — Success/Warning/Failed
- `schedule_enabled` (BOOLEAN)
- `repository_uid` (VARCHAR)
- `synced_at` (TIMESTAMP)
4.4. **`veeam_job_sessions`** — Recent backup job session results
- `instance_uid` (VARCHAR, PK)
- `job_uid` (VARCHAR, FK → veeam_backup_jobs)
- `organization_uid` (VARCHAR, FK → veeam_organizations)
- `status` (VARCHAR) — Success/Warning/Failed
- `start_time` (TIMESTAMP)
- `end_time` (TIMESTAMP)
- `duration_seconds` (INTEGER)
- `transferred_bytes` (BIGINT)
- `processed_bytes` (BIGINT)
- `bottleneck` (VARCHAR)
- `error_message` (TEXT)
- `synced_at` (TIMESTAMP)
4.5. **`veeam_protected_workloads`** — Protected VMs and physical servers
- `instance_uid` (VARCHAR, PK)
- `name` (VARCHAR)
- `organization_uid` (VARCHAR, FK → veeam_organizations)
- `platform` (VARCHAR) — VMware/Hyper-V/Physical/etc.
- `protection_status` (VARCHAR) — Protected/Unprotected/Partial
- `last_restore_point` (TIMESTAMP)
- `restore_point_count` (INTEGER)
- `total_backup_size_bytes` (BIGINT)
- `synced_at` (TIMESTAMP)
4.6. **`veeam_repositories`** — Backup repository capacity
- `instance_uid` (VARCHAR, PK)
- `name` (VARCHAR)
- `backup_server_uid` (VARCHAR, FK → veeam_backup_servers)
- `capacity_bytes` (BIGINT)
- `free_space_bytes` (BIGINT)
- `used_space_bytes` (BIGINT)
- `repository_type` (VARCHAR)
- `synced_at` (TIMESTAMP)
4.7. **`veeam_restore_points`** — Restore point history
- `instance_uid` (VARCHAR, PK)
- `workload_uid` (VARCHAR, FK → veeam_protected_workloads)
- `organization_uid` (VARCHAR, FK → veeam_organizations)
- `creation_date` (TIMESTAMP)
- `type` (VARCHAR) — Full/Incremental
- `size_bytes` (BIGINT)
- `synced_at` (TIMESTAMP)
### FR5: Veeam Sync Service
5.1. Create a Veeam sync service (`/lib/services/veeam-sync-service.ts`) following the pattern of the existing Autotask `SyncService`
5.2. Support full sync (all data) and incremental sync (changed data since last sync)
5.3. Sync entities in dependency order: organizations → backup servers → repositories → backup jobs → job sessions → protected workloads → restore points
5.4. Record sync history in the existing `sync_history` table with `sync_source: 'veeam'`
5.5. Log sync progress and errors using the existing `SyncLogger` pattern
5.6. Handle API pagination automatically (fetch all pages per entity)
### FR6: Sync Scheduler Integration
6.1. Add Veeam sync schedules to the existing sync scheduler (`/lib/services/sync-scheduler.ts`)
6.2. Default schedule: incremental sync every 30 minutes, full sync daily at 2:00 AM
6.3. Allow schedule configuration from the Admin → Sync page
6.4. Display Veeam sync status and history alongside Autotask sync data in the admin UI
### FR7: Backup Status Page
7.1. Create a new page at `/backup-status` accessible from the main navigation
7.2. Page layout sections:
**Summary Cards (top row):**
- Total protected workloads count
- Unprotected workloads count (with warning color if > 0)
- Last 24h job success rate (percentage)
- Failed jobs in last 24h (count, red if > 0)
- Total repository usage (used/total with percentage bar)
**Company Backup Overview (main table):**
- Company name (linked to Autotask company)
- Protected workload count
- Unprotected workload count
- Last backup job status (color-coded badge: green/yellow/red)
- Last successful backup timestamp
- Oldest restore point age
- Repository usage for company (if applicable)
- Expandable row to show individual workloads and jobs
**Filters:**
- Filter by company
- Filter by backup status (Success/Warning/Failed/All)
- Filter by protection status (Protected/Unprotected/All)
- Search by workload name
### FR8: Company Backup Detail View
8.1. Clicking a company row expands to show:
- List of all protected workloads with last restore point date and status
- List of all backup jobs with last run time and result
- Recent job session history (last 7 days) with status timeline
- Unprotected machines highlighted in red/warning
### FR9: API Endpoints
9.1. `GET /api/veeam/backup-status` — Summary statistics for dashboard cards
9.2. `GET /api/veeam/companies` — Company-level backup overview with aggregated stats
9.3. `GET /api/veeam/companies/[companyId]/workloads` — Protected workloads for a specific company
9.4. `GET /api/veeam/companies/[companyId]/jobs` — Backup jobs for a specific company
9.5. `GET /api/veeam/companies/[companyId]/sessions` — Recent job sessions for a specific company
9.6. `GET /api/veeam/repositories` — Repository capacity overview
9.7. `GET /api/veeam/sync` — Trigger manual Veeam sync (POST)
9.8. `GET /api/veeam/compliance` — Contract compliance summary and mismatch list
9.9. `GET /api/veeam/compliance/[companyId]` — Compliance details for a specific company
9.10. All endpoints read from PostgreSQL (synced data), not directly from VSPC API
### FR10: TypeScript Types
10.1. Create Veeam type definitions in `/lib/types/veeam.ts`:
- `VeeamOrganization` interface
- `VeeamBackupServer` interface
- `VeeamBackupJob` interface
- `VeeamJobSession` interface
- `VeeamProtectedWorkload` interface
- `VeeamRepository` interface
- `VeeamRestorePoint` interface
- `VeeamBackupStatusSummary` interface (for dashboard cards)
- `VeeamCompanyBackupOverview` interface (for company table)
- `VeeamComplianceResult` interface (for compliance mismatch records)
- `VeeamComplianceSummary` interface (for compliance summary cards)
10.2. Export types for use across the application
### FR11: Error Handling & Logging
11.1. Log all VSPC API errors to console with descriptive messages
11.2. Log sync progress (entities synced, records created/updated/skipped)
11.3. Never throw errors that would break the Backup Status page — show "Data unavailable" gracefully
11.4. Display last successful sync timestamp on the Backup Status page
11.5. Show warning banner if last sync is older than 2 hours
### FR12: Navigation Integration
12.1. Add "Backup Status" link to the main sidebar navigation
12.2. Use an appropriate Lucide icon (e.g., `HardDrive`, `Shield`, `Database`)
12.3. Position after existing navigation items
### FR13: Autotask Backup UDF Sync
**Note:** Autotask configuration item UDFs are NOT currently synced to PostgreSQL. The `mapConfigurationItem` function in `/lib/utils/entity-mapper.ts` does not include `userDefinedFields`. This must be addressed.
13.1. Add a `backup_type_udf` column (VARCHAR, nullable) to the `configuration_items` table via migration
13.2. During Autotask config item sync, extract the backup-type UDF (ID: `29693319`) from the `userDefinedFields` array and store its value in the `backup_type_udf` column
13.3. Update `mapConfigurationItem()` in `/lib/utils/entity-mapper.ts` to map the UDF value
13.4. Known UDF values to support:
- `Server Image`
- `Workstation Image`
- `Workstation File Based`
- Any other non-null/non-empty value should also be stored as-is
13.5. Add an index on `backup_type_udf` for efficient compliance queries
13.6. Ensure the Autotask API query for configuration items includes `userDefinedFields` in the response (verify the existing `queryEntity` call returns UDFs)
### FR14: Contract Compliance Logic
14.1. Define a config item as "contracted for backup" when ALL of the following are true:
- `backup_type_udf` is NOT NULL and NOT empty
- `contract_id` is NOT NULL
- The associated contract has `status` = active (numeric value TBD — verify from Autotask picklist)
- `is_active` = true
14.2. Match contracted config items to Veeam protected workloads using:
- Primary: hostname match (config item `reference_title` or RMM hostname vs Veeam workload `name`)
- Secondary: company match + name similarity (fuzzy)
14.3. Produce two mismatch lists per company:
- **Contracted but NOT backed up:** Config items meeting 14.1 criteria with no matching Veeam protected workload
- **Backed up but NOT contracted:** Veeam protected workloads with no matching config item that meets 14.1 criteria
14.4. Store compliance results in a `veeam_compliance_results` table:
- `id` (SERIAL, PK)
- `company_id` (INTEGER, FK → companies)
- `configuration_item_id` (INTEGER, nullable, FK → configuration_items)
- `veeam_workload_uid` (VARCHAR, nullable, FK → veeam_protected_workloads)
- `mismatch_type` (VARCHAR) — `contracted_not_backed_up` or `backed_up_not_contracted`
- `backup_type_udf` (VARCHAR, nullable) — The UDF value from the config item
- `device_name` (VARCHAR) — Name of the device for display
- `computed_at` (TIMESTAMP) — When this compliance check was run
14.5. Recompute compliance results after each Veeam sync completes
14.6. Log compliance computation results (total matched, total mismatches per type)
### FR15: Contract Compliance UI
15.1. Add a "Contract Compliance" tab to the Backup Status page (alongside the main backup overview)
15.2. **Compliance Summary Cards:**
- Total contracted backup devices (config items with backup UDF + active contract)
- Matched (contracted AND backed up in Veeam)
- Contracted but NOT backed up (red/warning count)
- Backed up but NOT contracted (amber/warning count)
15.3. **Compliance Detail Table:**
- Company name
- Device name
- Mismatch type (visual badge: "Missing Backup" in red, "No Contract" in amber)
- Backup type UDF value (Server Image, Workstation Image, etc.)
- Contract name (if applicable)
- Veeam workload name (if applicable)
- Filterable by company, mismatch type
15.4. **Company Backup Detail** (FR8) should also show compliance status:
- In the expanded company row, show a "Compliance" section listing any mismatches for that company
## Non-Goals (Out of Scope)
1. **Kiosk/Ticker Integration:** Backup data will NOT appear in the kiosk ticker or dashboard
2. **Backup Triggering:** Will NOT allow starting/stopping backup jobs from Pulse
3. **Restore Operations:** Will NOT support initiating restores from Pulse
4. **Auto-Remediation:** Will NOT automatically create contracts or backup jobs to fix compliance mismatches
5. **Alerting/Notifications:** Will NOT send email or other notifications for backup failures
6. **Veeam Cloud Connect:** Will NOT integrate with Cloud Connect tenant data specifically
7. **Real-Time Data:** All data is synced on schedule; no real-time/on-demand VSPC API calls from the UI
8. **Multi-Instance:** Will NOT support multiple VSPC instances (single instance only)
## Design Considerations
### UI Components
- Follow existing Pulse design patterns (dark theme, shadcn/ui components)
- Use same Card, Badge, Table, and Button components from shadcn/ui
- Maintain consistent spacing, typography, and color scheme
- Use Lucide icons for backup-related visuals
### Status Color Coding
- **Success:** Green badge/indicator
- **Warning:** Yellow/amber badge/indicator
- **Failed:** Red badge/indicator
- **Running:** Blue badge/indicator with spinner
- **Disabled:** Gray badge/indicator
### Page Layout
```
┌──────────────────────────────────────────────────────────────┐
│ Backup Status Last sync: 5m ago │
├──────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────┐│
│ │Protected │ │Unprotect.│ │Success % │ │ Failed │ │Repo ││
│ │ 142 │ │ 3 ⚠ │ │ 96.2% │ │ 4 🔴 │ │ 72% ││
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └─────┘│
├──────────────────────────────────────────────────────────────┤
│ [Filter: Company ▼] [Status ▼] [Protection ▼] [Search...] │
├──────────────────────────────────────────────────────────────┤
│ Company │ Protected │ Unprotected │ Status │ Last OK │
│────────────────┼───────────┼─────────────┼────────┼──────────│
│ Acme Corp │ 12 │ 0 │ ✅ │ 2h ago │
│ └─ [Expand to show workloads and jobs] │
│ Beta LLC │ 8 │ 1 │ ⚠️ │ 5h ago │
│ Gamma Inc │ 5 │ 0 │ 🔴 │ 26h ago │
└──────────────────────────────────────────────────────────────┘
```
### Repository Capacity Visualization
- Use progress bars showing used/total capacity
- Color-code: green (< 70%), yellow (70-85%), red (> 85%)
### Contract Compliance Tab Layout
```
┌──────────────────────────────────────────────────────────────┐
│ [Backup Overview] [Contract Compliance] │
├──────────────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Contracted │ │ Matched │ │ Missing │ │ No │ │
│ │ Backups │ │ │ │ Backup │ │ Contract │ │
│ │ 87 │ │ 82 ✅ │ │ 3 🔴 │ │ 2 🟠 │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ [Filter: Company ▼] [Mismatch Type ▼] [Search...] │
├──────────────────────────────────────────────────────────────┤
│ Company │ Device │ Type │ Backup UDF │Issue │
│─────────────┼────────────────┼──────────┼─────────────┼──────│
│ Acme Corp │ ACME-FS01 │ 🔴 Miss. │ Server Image│ No │
│ │ │ Backup │ │Veeam │
│ Beta LLC │ BETA-PC-042 │ 🟠 No │ │ No │
│ │ │ Contract │ │ UDF │
└──────────────────────────────────────────────────────────────┘
```
### Compliance Status Badges
- **Matched:** Green badge — contracted and backed up
- **Missing Backup:** Red badge — contracted but not found in Veeam
- **No Contract:** Amber badge — backed up in Veeam but no matching contract
## Technical Considerations
### VSPC API Integration
- **API Version:** REST API v3 (3.6.1)
- **Base URL:** `https://<hostname>:1280/api/v3`
- **Authentication:** API Key-based (`Authorization: Bearer <API-Key>`)
- **Pagination:** Offset-based with `offset` and `limit` query parameters
- **Filtering:** VSPC filter syntax (e.g., `filter=status eq "Failed"`)
- **Rate Limiting:** Respect VSPC throttling settings; implement exponential backoff
- **SSL:** VSPC may use self-signed certificates; support `NODE_TLS_REJECT_UNAUTHORIZED` env var for development
### Company Matching Logic
```typescript
// VSPC organizations have a companyId field that maps to Autotask Company ID
async function matchOrganizations(
vspcOrgs: VeeamOrganization[],
autotaskCompanies: Company[]
): Map<string, number> {
const mapping = new Map<string, number>(); // vspcOrgUid → autotaskCompanyId
for (const org of vspcOrgs) {
if (org.companyId) {
const match = autotaskCompanies.find(c => c.id === parseInt(org.companyId));
if (match) {
mapping.set(org.instanceUid, match.id);
}
}
}
return mapping;
}
```
### Dependencies
- No new npm packages required (use built-in fetch)
- Leverage existing service patterns (`autotask-client.ts`, `sync-service.ts`, `entity-sync.ts`)
- Use existing UI components from shadcn/ui
- Use existing PostgreSQL client (`postgres-client.ts`)
- Use existing sync scheduler infrastructure
### File Structure
```
/lib/services/veeam-client.ts # VSPC API client
/lib/services/veeam-factory.ts # Singleton factory for client
/lib/services/veeam-sync-service.ts # Sync orchestration service
/lib/services/veeam-compliance-service.ts # Contract compliance computation
/lib/types/veeam.ts # TypeScript types
/app/api/veeam/backup-status/route.ts # Summary stats endpoint
/app/api/veeam/companies/route.ts # Company backup overview
/app/api/veeam/companies/[companyId]/workloads/route.ts
/app/api/veeam/companies/[companyId]/jobs/route.ts
/app/api/veeam/companies/[companyId]/sessions/route.ts
/app/api/veeam/repositories/route.ts # Repository capacity
/app/api/veeam/sync/route.ts # Manual sync trigger
/app/api/veeam/compliance/route.ts # Compliance summary + list
/app/api/veeam/compliance/[companyId]/route.ts # Per-company compliance
/app/backup-status/page.tsx # Main Backup Status page
/components/backup/backup-summary-cards.tsx # Summary KPI cards
/components/backup/company-backup-table.tsx # Company overview table
/components/backup/company-backup-detail.tsx # Expanded company detail
/components/backup/repository-overview.tsx # Repository capacity view
/components/backup/compliance-summary-cards.tsx # Compliance KPI cards
/components/backup/compliance-detail-table.tsx # Compliance mismatch table
/migrations/023_create_veeam_tables.sql # Veeam tables + compliance results
/migrations/024_add_backup_type_udf.sql # Add backup_type_udf to configuration_items
```
### Environment Variables
```bash
VEEAM_VSPC_URL=https://vac.wulfconsulting.com:1280
VEEAM_VSPC_API_KEY=your-api-key-here
# Optional: set to '0' if VSPC uses self-signed certs (dev only)
# NODE_TLS_REJECT_UNAUTHORIZED=0
```
### Database Migration
- Migration file: `023_create_veeam_tables.sql`
- Creates all 7 Veeam tables with appropriate indexes
- Creates `veeam_compliance_results` table
- Add indexes on `organization_uid`, `company_id`, `status`, `synced_at` columns for query performance
- Add index on `veeam_job_sessions.start_time` for time-range queries
- Add index on `veeam_compliance_results.company_id` and `mismatch_type`
- Migration file: `024_add_backup_type_udf.sql`
- Adds `backup_type_udf` (VARCHAR, nullable) column to `configuration_items` table
- Adds index on `backup_type_udf` for compliance queries
- Note: Existing config items will have NULL for this column until the next Autotask sync runs
## Success Metrics
1. **Sync Reliability:** 99%+ of scheduled syncs complete successfully
2. **Data Freshness:** Backup status data is never more than 1 hour old during business hours
3. **Company Match Rate:** 95%+ of VSPC organizations are matched to Autotask companies via Company ID
4. **Page Load Performance:** Backup Status page loads within 2 seconds for typical MSP (< 50 companies)
5. **Coverage Visibility:** 100% of VSPC-managed workloads are visible in the Backup Status page
6. **Adoption:** Administrators check the Backup Status page at least once daily within the first month
7. **Compliance Accuracy:** 95%+ of contracted backup devices are correctly matched to Veeam workloads (hostname matching)
8. **Revenue Protection:** Identify 100% of Veeam-backed workloads that lack a corresponding active contract
## Open Questions
1. **VSPC API Key Permissions:** What permission level is needed for the API key? Read-only access to all organizations?
- *Recommendation:* Create an API key with `rest` scope and `isReadAccessOnly: true`
2. **Session History Depth:** How many days of job session history should we sync and retain?
- *Recommendation:* Sync last 30 days, retain 90 days, purge older records
3. **Self-Signed Certificates:** Does the VSPC instance use a self-signed SSL certificate?
- *Recommendation:* Support `NODE_TLS_REJECT_UNAUTHORIZED` env var; document in setup guide
4. **VSPC Version Compatibility:** Is the VSPC instance running v9.1 (REST API 3.6.1) or a different version?
- *Recommendation:* Target v3 API which is stable across recent VSPC versions
5. **Unprotected Machine Detection:** How should "unprotected" be defined — machines with no backup job, or machines whose last backup is older than X hours?
- *Recommendation:* Both — flag machines with no job AND machines with stale backups (configurable threshold, default 24h)
6. **Repository Scope:** Should we show all repositories or only those relevant to managed clients?
- *Recommendation:* Show all repositories with filtering by backup server
7. **UDF Field ID Verification:** The backup-type UDF is believed to be ID `29693319`. This needs to be verified against the Autotask API by querying the UDF field definitions for ConfigurationItems.
- *Action:* Query `GET /atservicesrest/v1.0/ConfigurationItemUserDefinedFields` or inspect a config item with a known backup UDF value to confirm the field ID and name
8. **Contract Active Status Value:** What is the numeric value for "Active" contract status in Autotask?
- *Action:* Query the Autotask picklist for contract statuses to determine the correct numeric value
9. **Hostname Matching Accuracy:** Config item `reference_title` may not always match the Veeam workload `name` exactly. What fallback matching strategies should be used?
- *Recommendation:* Try `reference_title`, then `rmmDeviceAuditHostname`, then `serial_number`. Allow manual override mapping in a future iteration
10. **UDF Values Completeness:** Are "Server Image", "Workstation Image", and "Workstation File Based" the only backup UDF values, or are there others?
- *Action:* Query Autotask for the UDF picklist values to get the complete list
## Implementation Notes for Developers
### Getting Started
1. Review existing Autotask sync service (`sync-service.ts`, `entity-sync.ts`) as the primary reference pattern
2. Review VSPC REST API documentation: https://helpcenter.veeam.com/references/vac/9.1/rest/3.6.1/tag/SectionAbout
3. Set up VSPC API key with read-only REST access
4. Test API connectivity using curl or Postman before coding
5. Run the database migration to create Veeam tables
### Sync Implementation Order
1. Implement `veeam-client.ts` with authentication and basic GET requests
2. Implement organization sync first (simplest entity, needed for FK references)
3. Add backup servers and repositories
4. Add backup jobs and job sessions
5. Add protected workloads and restore points
6. Wire up to sync scheduler
7. Add `backup_type_udf` column migration and update `mapConfigurationItem()` in entity-mapper
8. Run an Autotask sync to populate `backup_type_udf` values
9. Implement `veeam-compliance-service.ts` (depends on both Veeam and Autotask data being synced)
10. Build the Contract Compliance UI tab
### Testing Checklist
- [ ] Verify API key authentication works
- [ ] Test organization fetch and company matching
- [ ] Test full sync of all entities
- [ ] Test incremental sync (only changed records)
- [ ] Test with VSPC API unavailable (graceful failure)
- [ ] Test pagination with large datasets (100+ workloads)
- [ ] Verify Backup Status page renders correctly with real data
- [ ] Verify Backup Status page renders gracefully with no data
- [ ] Test company filter and status filter on Backup Status page
- [ ] Check database migration runs cleanly on existing schema
- [ ] Verify sync scheduler runs Veeam sync on configured schedule
- [ ] Verify `backup_type_udf` is populated after Autotask sync
- [ ] Test compliance computation with known contracted devices
- [ ] Verify "contracted but not backed up" mismatches are correctly identified
- [ ] Verify "backed up but not contracted" mismatches are correctly identified
- [ ] Test compliance UI tab renders correctly with mismatch data
- [ ] Test compliance UI with zero mismatches (all green)
- [ ] Verify compliance recomputes after each Veeam sync
### Code Review Focus Areas
- Error handling completeness (VSPC API can be unreliable)
- TypeScript type safety for all VSPC API responses
- Consistent code style with existing services
- Proper logging for debugging sync issues
- Performance (batch inserts, avoid N+1 queries)
- Database index usage for Backup Status page queries

View file

@ -0,0 +1,101 @@
## Relevant Files
- `lib/services/veeam-client.ts` - VSPC REST API client handling authentication, pagination, filtering, and rate limiting.
- `lib/services/veeam-factory.ts` - Singleton factory for the Veeam client instance.
- `lib/services/veeam-sync-service.ts` - Sync orchestration service for fetching and persisting Veeam data to PostgreSQL.
- `lib/services/veeam-compliance-service.ts` - Contract compliance computation logic (cross-referencing Autotask config items with Veeam workloads).
- `lib/types/veeam.ts` - TypeScript type definitions for all Veeam entities and UI models.
- `lib/utils/entity-mapper.ts` - Existing entity mapper; needs update to map backup UDF from Autotask config items.
- `lib/services/sync-scheduler.ts` - Existing sync scheduler; needs Veeam sync schedule integration.
- `migrations/023_create_veeam_tables.sql` - Database migration for all Veeam tables and compliance results table.
- `migrations/024_add_backup_type_udf.sql` - Database migration to add `backup_type_udf` column to `configuration_items`.
- `app/api/veeam/backup-status/route.ts` - API endpoint for backup summary statistics.
- `app/api/veeam/companies/route.ts` - API endpoint for company-level backup overview.
- `app/api/veeam/companies/[companyId]/workloads/route.ts` - API endpoint for per-company protected workloads.
- `app/api/veeam/companies/[companyId]/jobs/route.ts` - API endpoint for per-company backup jobs.
- `app/api/veeam/companies/[companyId]/sessions/route.ts` - API endpoint for per-company job sessions.
- `app/api/veeam/repositories/route.ts` - API endpoint for repository capacity overview.
- `app/api/veeam/sync/route.ts` - API endpoint to trigger manual Veeam sync.
- `app/api/veeam/compliance/route.ts` - API endpoint for compliance summary and mismatch list.
- `app/api/veeam/compliance/[companyId]/route.ts` - API endpoint for per-company compliance details.
- `app/backup-status/page.tsx` - Main Backup Status page with tabs for Backup Overview and Contract Compliance.
- `components/backup/backup-summary-cards.tsx` - Summary KPI cards component (protected, unprotected, success rate, failed, repo usage).
- `components/backup/company-backup-table.tsx` - Company backup overview table with expandable rows.
- `components/backup/company-backup-detail.tsx` - Expanded company detail view (workloads, jobs, sessions, compliance).
- `components/backup/repository-overview.tsx` - Repository capacity visualization component.
- `components/backup/compliance-summary-cards.tsx` - Compliance KPI cards (contracted, matched, missing backup, no contract).
- `components/backup/compliance-detail-table.tsx` - Compliance mismatch detail table with filters.
### Notes
- Unit tests should typically be placed alongside the code files they are testing (e.g., `veeam-client.ts` and `veeam-client.test.ts` in the same directory).
- Use `npx jest [optional/path/to/test/file]` to run tests. Running without a path executes all tests found by the Jest configuration.
## Tasks
- [x] 1.0 Database Schema & Migrations
- [x] 1.1 Create migration `023_create_veeam_tables.sql` with tables: `veeam_organizations`, `veeam_backup_servers`, `veeam_backup_jobs` (VM jobs), `veeam_backup_agent_jobs` (workstation/physical jobs), `veeam_protected_workloads`, `veeam_repositories`, `veeam_compliance_results`. Note: `veeam_job_sessions` and `veeam_restore_points` were removed — session data (lastRun, lastEndTime, status, bottleneck, failureMessage) is embedded directly on job objects in the VSPC API. Agent jobs (753 total) were added as a separate table since they have a different schema than backup server jobs (182 total).
- [x] 1.2 Add `veeam_compliance_results` table to the same migration (included in 023_create_veeam_tables.sql)
- [x] 1.3 Add indexes on `organization_uid`, `company_id`, `status`, `synced_at`, `last_run` across Veeam tables; indexes on `veeam_compliance_results.company_id`, `mismatch_type`, and `computed_at`
- [x] 1.4 Create migration `024_add_backup_type_udf.sql` to add `backup_type_udf` (VARCHAR, nullable) column to the existing `configuration_items` table, with an index on `backup_type_udf`
- [x] 1.5 Test that both migrations run cleanly against the existing database schema without errors — verified 7 veeam_* tables created and backup_type_udf column added to configuration_items
- [x] 2.0 VSPC API Client & TypeScript Types
- [x] 2.1 Create `/lib/types/veeam.ts` with interfaces for VSPC API response types (`VspcOrganization`, `VspcBackupServer`, `VspcBackupJob`, `VspcBackupAgentJob`, `VspcProtectedWorkload`, `VspcRepository`), DB entity types (`VeeamOrganization`, `VeeamBackupServer`, `VeeamBackupJob`, `VeeamBackupAgentJob`, `VeeamProtectedWorkload`, `VeeamRepository`, `VeeamComplianceResult`), and UI types (`VeeamBackupStatusSummary`, `VeeamCompanyBackupOverview`, `VeeamComplianceSummary`). Updated to match actual API shapes.
- [x] 2.2 Create `/lib/services/veeam-client.ts` with Bearer token auth, base URL from `VEEAM_VSPC_URL` env var
- [x] 2.3 Implement `fetchAllPages<T>()` generic paginated GET with `offset`/`limit`, auto-fetches all pages
- [x] 2.4 Implement VSPC `filter` query parameter support via optional filter arg on `fetchAllPages`
- [x] 2.5 Add rate limiting (100 req/min with wait) and error handling with descriptive logging
- [x] 2.6 Implement data-fetching methods: `getOrganizations()`, `getBackupServers()`, `getBackupJobs()`, `getBackupAgentJobs()`, `getProtectedWorkloads()`, `getRepositories()`, `testConnection()`. Note: `getJobSessions()` and `getRestorePoints()` removed — data is embedded in job/workload objects.
- [x] 2.7 Create `/lib/services/veeam-factory.ts` singleton factory following `auvik-factory.ts` pattern
- [x] 2.8 N/A — no `.env.example` exists; env vars already in `.env`
- [x] 2.9 Tested: connection successful, 58 organizations fetched, pagination working correctly
- [x] 3.0 Veeam Sync Service & Scheduler Integration
- [x] 3.1 Created `/lib/services/veeam-sync-service.ts` with full/incremental sync, entity-level error handling, sync history recording
- [x] 3.2 Full sync implemented: orgs → servers → repos → backup jobs → agent jobs → workloads. FK safety checks prevent constraint violations.
- [x] 3.3 Incremental sync implemented (same as full since VSPC API lacks last-modified filtering; upserts make unchanged rows no-ops)
- [x] 3.4 Company matching: parses companyId string, validates against companies table, sets null if no match
- [x] 3.5 Sync history recorded in sync_history table with entity_type='veeam'
- [x] 3.6 Pagination handled by VeeamClient.fetchAllPages() — auto-fetches all pages per entity
- [x] 3.7 Added veeam-incremental (*/30 * * * *) and veeam-full (0 2 * * *) schedules to sync-scheduler.ts. Updated CHECK constraint and ScheduleConfig type.
- [x] 3.8 Veeam sync schedules visible in Admin Sync page via existing sync_schedules table
- [x] 3.9 Created POST /api/veeam/sync (triggers sync) and GET /api/veeam/sync (status check)
- [x] 3.10 Tested: full sync completed in 2.8s — 58 orgs, 47 servers, 209 repos, 182 jobs, 753 agent jobs, 157 workloads = 1,406 records
- [x] 4.0 Autotask Backup UDF Sync
- [x] 4.1 UDF field ID 29693319 confirmed from PRD; mapper searches by name 'Backup Type' or ID '29693319'
- [x] 4.2 Autotask queryEntity returns userDefinedFields by default for ConfigurationItems (verified in types/autotask.ts)
- [x] 4.3 Updated mapConfigurationItem() to extract backup UDF from userDefinedFields array and map to backup_type_udf
- [x] 4.4 Migration 024 already applied (Task 1.5)
- [ ] 4.5 Pending: trigger Autotask config items sync to populate backup_type_udf values (requires Autotask sync run)
- [ ] 4.6 Pending: query Autotask picklist for complete UDF values (requires Autotask API call)
- [ ] 4.7 Contract status = 1 used for Active in compliance queries (standard Autotask value)
- [x] 5.0 Backup Status API Endpoints
- [x] 5.1 Created GET /api/veeam/backup-status — summary stats with 24h success rate, failed/warning counts
- [x] 5.2 Created GET /api/veeam/companies — company-level overview with aggregated job counts and statuses
- [x] 5.3 Created GET /api/veeam/companies/[companyId]/workloads — per-company protected workloads
- [x] 5.4 Created GET /api/veeam/companies/[companyId]/jobs — server jobs + agent jobs per company
- [x] 5.5 N/A — sessions are embedded in job objects; jobs endpoint returns last run/status/duration
- [x] 5.6 Created GET /api/veeam/repositories — with backup server name join
- [x] 5.7 Created POST/GET /api/veeam/sync — trigger sync and check status
- [x] 5.8 All endpoints return graceful empty arrays/objects on error via try/catch
- [ ] 5.9 Pending: test endpoints with real synced data via browser
- [x] 6.0 Backup Status Page & UI Components
- [x] 6.1 Created backup-summary-cards.tsx — 5 KPI cards: Protected Workloads, Total Jobs, 24h Success Rate, Failed Jobs, Warnings
- [x] 6.2 Created company-backup-table.tsx — filterable/searchable table with expandable rows, status badges
- [x] 6.3 Created company-backup-detail.tsx — expanded view with workloads, jobs (server+agent), compliance issues
- [ ] 6.4 Pending: repository-overview.tsx with capacity progress bars (repos have minimal data from API)
- [x] 6.5 Created /app/backup-status/page.tsx — tabs for Backup Overview + Contract Compliance, sync button, stale warning
- [x] 6.6 Filters: search by company name, filter by status (All/Success/Warning/Failed)
- [x] 6.7 Added HardDrive icon + Backup Status link to app-navigation.tsx
- [ ] 6.8 Pending: verify page renders with real data via browser
- [ ] 6.9 Pending: verify responsive layout
- [x] 7.0 Contract Compliance Engine & UI
- [x] 7.1 Created GET /api/veeam/compliance — summary + mismatch list with company names
- [x] 7.2 Created GET /api/veeam/compliance/[companyId] — per-company compliance details
- [x] 7.3 Created veeam-compliance-service.ts — matches config items (backup_type_udf + company active contract) against Veeam workloads by hostname. Note: uses company-level contract matching since config_items don't have direct contract_id.
- [x] 7.4 Compliance results stored via DELETE + INSERT in veeam_compliance_results; totals logged
- [x] 7.5 Compliance auto-runs after successful Veeam sync (wired in veeam-sync-service.ts)
- [x] 7.6 Created compliance-summary-cards.tsx — 4 KPI cards
- [x] 7.7 Created compliance-detail-table.tsx — filterable by mismatch type and search
- [x] 7.8 Compliance tab integrated into backup-status/page.tsx with badge count
- [x] 7.9 Company backup detail shows compliance issues section when mismatches exist
- [ ] 7.10 Pending: test compliance with known data after Autotask sync populates backup_type_udf