wulf-pulse/components/backup/company-backup-detail.tsx
lorentz 5dc7a7e66b 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
2026-02-11 21:04:28 -05:00

209 lines
7.5 KiB
TypeScript

'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>
);
}