- 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
160 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|