feat: Veeam VSPC backup integration - sync, compliance, UI
- Database: 7 Veeam tables + backup_type_udf column on configuration_items - API Client: VSPC REST API v3 client with pagination, rate limiting, Bearer auth - Sync Service: full/incremental sync for orgs, servers, repos, jobs, agent jobs, workloads - Scheduler: veeam-incremental (30min) and veeam-full (daily 2AM) schedules - Compliance Engine: cross-references Autotask config items vs Veeam workloads - API Endpoints: backup-status, companies, workloads, jobs, repos, compliance, sync - UI: Backup Status page with Overview + Contract Compliance tabs - Navigation: added Backup Status link with HardDrive icon - Docker: added VEEAM_VSPC_URL and VEEAM_VSPC_API_KEY env vars to compose
This commit is contained in:
parent
a1e0e7c7c0
commit
5dc7a7e66b
27 changed files with 3408 additions and 7 deletions
78
components/backup/backup-summary-cards.tsx
Normal file
78
components/backup/backup-summary-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
components/backup/company-backup-detail.tsx
Normal file
209
components/backup/company-backup-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
components/backup/company-backup-table.tsx
Normal file
160
components/backup/company-backup-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
components/backup/compliance-detail-table.tsx
Normal file
127
components/backup/compliance-detail-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
components/backup/compliance-summary-cards.tsx
Normal file
66
components/backup/compliance-summary-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue