wulf-pulse/components/backup/company-backup-table.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

160 lines
5.9 KiB
TypeScript

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