- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison) - Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis - Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison) - Add veeam-analysis-state.ts and rmm-device-resolver.ts services - Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis - Add backup-status page updates and nav links for new Veeam pages - Add scripts: deactivate-cis-for-inactive-companies, workstation category updates - Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt - Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
575 lines
24 KiB
TypeScript
575 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare,
|
|
Ticket, ChevronRight, ChevronDown, Sparkles, Loader2,
|
|
} from 'lucide-react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
type MatchStatus = 'both' | 'pulse_only' | 'datto_only' | 'offline_suppressed';
|
|
|
|
interface AtTicket {
|
|
ticket_number: string;
|
|
title: string;
|
|
status: number | null;
|
|
priority: number | null;
|
|
created_at: string | null;
|
|
completed_at: string | null;
|
|
datto_source: string | null;
|
|
}
|
|
|
|
interface MatchRow {
|
|
key: string;
|
|
hostname: string | null;
|
|
org_name: string | null;
|
|
company_id: number | null;
|
|
status: MatchStatus;
|
|
pulse: {
|
|
job_name: string;
|
|
priority_level: string;
|
|
hours_overdue: number;
|
|
failure_category: string | null;
|
|
opened_at: string;
|
|
} | null;
|
|
offline: { hours_offline: number; last_suppressed: string } | null;
|
|
at_ticket_count: number;
|
|
at_open_count: number;
|
|
at_tickets: AtTicket[];
|
|
}
|
|
|
|
interface ClientGroup {
|
|
org_name: string | null;
|
|
company_id: number | null;
|
|
rows: MatchRow[];
|
|
counts: Record<MatchStatus, number>;
|
|
totalAtTickets: number;
|
|
totalAtOpen: number;
|
|
}
|
|
|
|
interface ComparisonData {
|
|
period: string;
|
|
summary: {
|
|
pulse_open: number;
|
|
datto_at_total: number;
|
|
datto_at_open: number;
|
|
both: number;
|
|
pulse_only: number;
|
|
datto_only: number;
|
|
offline_suppressed: number;
|
|
};
|
|
matches: MatchRow[];
|
|
}
|
|
|
|
interface AnalysisResult {
|
|
ticket_number: string;
|
|
hostname: string;
|
|
org_name: string;
|
|
hours_offline: number | null;
|
|
total_hours_logged: number;
|
|
note_count: number;
|
|
model: string;
|
|
analysis: {
|
|
would_suppress_correctly?: boolean;
|
|
confidence?: string;
|
|
work_summary?: string;
|
|
reasoning?: string;
|
|
recommendation?: string;
|
|
raw?: string;
|
|
};
|
|
}
|
|
|
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
const PERIODS = [
|
|
{ value: '1d', label: 'Last 24h' },
|
|
{ value: '7d', label: 'Last 7d' },
|
|
{ value: '14d', label: 'Last 14d' },
|
|
{ value: '30d', label: 'Last 30d' },
|
|
];
|
|
|
|
const CLOSED_STATUSES = [5, 29832279, 29832280];
|
|
|
|
const STATUS_CONFIG: Record<MatchStatus, {
|
|
label: string;
|
|
badgeVariant: 'default' | 'destructive' | 'secondary' | 'outline';
|
|
rowAccent: string;
|
|
}> = {
|
|
both: { label: 'Both', badgeVariant: 'default', rowAccent: 'border-l-2 border-l-blue-500/50' },
|
|
pulse_only: { label: 'Pulse Only', badgeVariant: 'destructive', rowAccent: 'border-l-2 border-l-destructive/50' },
|
|
datto_only: { label: 'Datto/AT', badgeVariant: 'secondary', rowAccent: 'border-l-2 border-l-orange-400/50' },
|
|
offline_suppressed: { label: 'Offline', badgeVariant: 'outline', rowAccent: 'border-l-2 border-l-muted-foreground/40' },
|
|
};
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function timeAgo(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return '—';
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const h = Math.floor(diff / 3_600_000);
|
|
if (h < 1) return 'Just now';
|
|
if (h < 24) return `${h}h ago`;
|
|
return `${Math.floor(h / 24)}d ago`;
|
|
}
|
|
|
|
function atStatusOpen(status: number | null): boolean {
|
|
return !CLOSED_STATUSES.includes(status ?? -1);
|
|
}
|
|
|
|
function atStatusLabel(status: number | null): string {
|
|
const map: Record<number, string> = { 1: 'New', 5: 'Complete', 8: 'In Progress', 47: 'Waiting' };
|
|
return map[status ?? -1] ?? (status != null ? `#${status}` : '?');
|
|
}
|
|
|
|
function PriorityBadge({ level }: { level: string }) {
|
|
const cls = level === 'critical' ? 'text-destructive border-destructive'
|
|
: level === 'high' ? 'text-orange-500 border-orange-500'
|
|
: 'text-muted-foreground border-muted-foreground/40';
|
|
return <Badge variant="outline" className={`text-[10px] px-1.5 ${cls}`}>{level}</Badge>;
|
|
}
|
|
|
|
// ── Analyze Dialog ────────────────────────────────────────────────────────────
|
|
|
|
function AnalyzeDialog({
|
|
ticket, hostname, orgName, hoursOffline, onClose,
|
|
}: {
|
|
ticket: AtTicket;
|
|
hostname: string;
|
|
orgName: string;
|
|
hoursOffline?: number;
|
|
onClose: () => void;
|
|
}) {
|
|
const [loading, setLoading] = useState(true);
|
|
const [result, setResult] = useState<AnalysisResult | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/veeam/rpo-analyze', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
at_ticket_number: ticket.ticket_number,
|
|
hostname,
|
|
org_name: orgName,
|
|
hours_offline: hoursOffline,
|
|
}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(d => { if (d.error) setError(d.error); else setResult(d); })
|
|
.catch(e => setError(e.message))
|
|
.finally(() => setLoading(false));
|
|
}, [ticket.ticket_number, hostname, orgName, hoursOffline]);
|
|
|
|
const a = result?.analysis;
|
|
const suppressed = a?.would_suppress_correctly;
|
|
|
|
return (
|
|
<Dialog open onOpenChange={onClose}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-4 w-4 text-blue-500" />
|
|
RPO Suppression Analysis
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{ticket.ticket_number} · {hostname} · {orgName}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{loading && (
|
|
<div className="flex items-center gap-2 py-8 justify-center text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-sm">Analyzing ticket work...</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-sm text-destructive py-4">{error}</div>
|
|
)}
|
|
|
|
{result && a && (
|
|
<div className="space-y-4 py-2">
|
|
{/* Verdict */}
|
|
<div className={`rounded-lg border px-4 py-3 flex items-start gap-3 ${
|
|
suppressed ? 'border-green-500/30 bg-green-500/5' : 'border-orange-500/30 bg-orange-500/5'
|
|
}`}>
|
|
<div className="mt-0.5">
|
|
{suppressed
|
|
? <CheckCircle2 className="h-5 w-5 text-green-500" />
|
|
: <AlertTriangle className="h-5 w-5 text-orange-500" />}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-sm">
|
|
{suppressed
|
|
? 'Suppression would have been correct'
|
|
: 'Human intervention was needed'}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
Confidence: {a.confidence ?? 'unknown'}
|
|
{result.total_hours_logged > 0 && ` · ${result.total_hours_logged.toFixed(2)}h logged`}
|
|
{result.note_count > 0 && ` · ${result.note_count} note${result.note_count !== 1 ? 's' : ''}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Work summary */}
|
|
{a.work_summary && (
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Work Summary</div>
|
|
<p className="text-sm">{a.work_summary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Reasoning */}
|
|
{a.reasoning && (
|
|
<div>
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Reasoning</div>
|
|
<p className="text-sm text-muted-foreground">{a.reasoning}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recommendation */}
|
|
{a.recommendation && (
|
|
<div className="rounded border px-3 py-2 bg-muted/30">
|
|
<p className="text-xs text-muted-foreground">{a.recommendation}</p>
|
|
</div>
|
|
)}
|
|
|
|
{a.raw && <pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-40">{a.raw}</pre>}
|
|
|
|
<div className="text-[10px] text-muted-foreground">Model: {result.model}</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ── AT Ticket Cell ────────────────────────────────────────────────────────────
|
|
|
|
function AtTicketCell({ tickets, ticketCount, hostname, orgName, hoursOffline }: {
|
|
tickets: AtTicket[];
|
|
ticketCount: number;
|
|
hostname: string | null;
|
|
orgName: string | null;
|
|
hoursOffline?: number;
|
|
}) {
|
|
const [analyzing, setAnalyzing] = useState<AtTicket | null>(null);
|
|
|
|
if (tickets.length === 0) return <span className="text-muted-foreground">—</span>;
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
{tickets.map((t, i) => {
|
|
const isOpen = atStatusOpen(t.status);
|
|
const isClosed = !isOpen;
|
|
return (
|
|
<div key={i} className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="font-mono font-medium">{t.ticket_number}</span>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-[10px] h-4 px-1 ${isOpen ? 'text-orange-500 border-orange-500' : 'text-green-600 border-green-600/50'}`}
|
|
>
|
|
{atStatusLabel(t.status)}
|
|
</Badge>
|
|
{t.datto_source && (
|
|
<span className="text-muted-foreground text-[10px]">{t.datto_source}</span>
|
|
)}
|
|
<span className="text-muted-foreground text-[10px]">{timeAgo(t.created_at)}</span>
|
|
{isClosed && hostname && (
|
|
<button
|
|
onClick={() => setAnalyzing(t)}
|
|
className="inline-flex items-center gap-0.5 text-[10px] text-blue-500 hover:text-blue-400 transition-colors"
|
|
>
|
|
<Sparkles className="h-2.5 w-2.5" />
|
|
Analyze
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{ticketCount > 5 && (
|
|
<div className="text-muted-foreground text-[10px]">+{ticketCount - 5} more</div>
|
|
)}
|
|
</div>
|
|
|
|
{analyzing && (
|
|
<AnalyzeDialog
|
|
ticket={analyzing}
|
|
hostname={hostname ?? ''}
|
|
orgName={orgName ?? ''}
|
|
hoursOffline={hoursOffline}
|
|
onClose={() => setAnalyzing(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Client Group Row ──────────────────────────────────────────────────────────
|
|
|
|
function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpen: boolean }) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
|
|
const actionable = group.counts.both + group.counts.pulse_only;
|
|
|
|
return (
|
|
<>
|
|
{/* Group summary header — columns align with the detail table below */}
|
|
<tr
|
|
className="border-b bg-muted/30 hover:bg-muted/50 cursor-pointer select-none"
|
|
onClick={() => setOpen(o => !o)}
|
|
>
|
|
{/* Device col: chevron + org name */}
|
|
<td className="pl-3 pr-2 py-2.5 w-44">
|
|
<div className="flex items-center gap-1.5">
|
|
{open
|
|
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
|
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />}
|
|
<span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span>
|
|
</div>
|
|
</td>
|
|
{/* Match col: status pills */}
|
|
<td className="px-3 py-2.5 w-36">
|
|
<div className="flex flex-wrap gap-1">
|
|
{group.counts.both > 0 && (
|
|
<Badge variant="default" className="text-[10px] h-4 px-1">{group.counts.both} Both</Badge>
|
|
)}
|
|
{group.counts.pulse_only > 0 && (
|
|
<Badge variant="destructive" className="text-[10px] h-4 px-1">{group.counts.pulse_only} Pulse</Badge>
|
|
)}
|
|
{group.counts.datto_only > 0 && (
|
|
<Badge variant="secondary" className="text-[10px] h-4 px-1">{group.counts.datto_only} Datto</Badge>
|
|
)}
|
|
{group.counts.offline_suppressed > 0 && (
|
|
<Badge variant="outline" className="text-[10px] h-4 px-1">{group.counts.offline_suppressed} Offline</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
{/* Pulse shadow col: total devices flagged */}
|
|
<td className="px-3 py-2.5 text-xs text-muted-foreground">
|
|
{actionable > 0
|
|
? <span className="font-medium text-foreground">{actionable} device{actionable !== 1 ? 's' : ''} need attention</span>
|
|
: <span>{group.rows.length} device{group.rows.length !== 1 ? 's' : ''}</span>}
|
|
</td>
|
|
{/* AT tickets col: ticket count */}
|
|
<td className="px-3 py-2.5 text-xs text-muted-foreground">
|
|
{group.totalAtTickets > 0
|
|
? <>{group.totalAtTickets} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</>
|
|
: <span>—</span>}
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Device detail rows */}
|
|
{open && group.rows.map((row) => {
|
|
const cfg = STATUS_CONFIG[row.status];
|
|
return (
|
|
<tr key={row.key} className={`border-b last:border-0 hover:bg-muted/10 align-top text-xs ${cfg.rowAccent}`}>
|
|
<td className="pl-9 pr-3 py-2.5 font-mono font-medium w-44 text-[11px]">
|
|
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
|
|
</td>
|
|
<td className="px-3 py-2.5 w-36">
|
|
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
|
|
</td>
|
|
<td className="px-3 py-2.5 space-y-0.5 max-w-[240px]">
|
|
{row.pulse ? (
|
|
<>
|
|
<div className="flex items-center gap-1.5">
|
|
<PriorityBadge level={row.pulse.priority_level} />
|
|
<span className="font-medium">{row.pulse.hours_overdue}h overdue</span>
|
|
</div>
|
|
<div className="text-muted-foreground truncate">{row.pulse.failure_category ?? '—'}</div>
|
|
<div className="text-muted-foreground text-[10px]">since {timeAgo(row.pulse.opened_at)}</div>
|
|
</>
|
|
) : row.offline ? (
|
|
<span className="flex items-center gap-1 text-muted-foreground">
|
|
<WifiOff className="h-3 w-3" />offline {Math.round(row.offline.hours_offline)}h
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-3 py-2.5">
|
|
<AtTicketCell
|
|
tickets={row.at_tickets}
|
|
ticketCount={row.at_ticket_count}
|
|
hostname={row.hostname}
|
|
orgName={row.org_name}
|
|
hoursOffline={row.offline?.hours_offline}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
export default function VeeamComparisonPage() {
|
|
const [data, setData] = useState<ComparisonData | null>(null);
|
|
const [period, setPeriod] = useState('7d');
|
|
const [loading, setLoading] = useState(true);
|
|
const [filter, setFilter] = useState<MatchStatus | 'all'>('all');
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/veeam/rpo-comparison?period=${period}`);
|
|
setData(await res.json());
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [period]);
|
|
|
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
|
|
const groups = useMemo<ClientGroup[]>(() => {
|
|
if (!data) return [];
|
|
const filtered = data.matches.filter(m => filter === 'all' || m.status === filter);
|
|
|
|
const byOrg = new Map<string, ClientGroup>();
|
|
for (const row of filtered) {
|
|
const key = row.org_name ?? '(Unknown)';
|
|
if (!byOrg.has(key)) {
|
|
byOrg.set(key, {
|
|
org_name: row.org_name, company_id: row.company_id,
|
|
rows: [], counts: { both: 0, pulse_only: 0, datto_only: 0, offline_suppressed: 0 },
|
|
totalAtTickets: 0, totalAtOpen: 0,
|
|
});
|
|
}
|
|
const g = byOrg.get(key)!;
|
|
g.rows.push(row);
|
|
g.counts[row.status]++;
|
|
g.totalAtTickets += row.at_ticket_count;
|
|
g.totalAtOpen += row.at_open_count;
|
|
}
|
|
|
|
return Array.from(byOrg.values()).sort((a, b) => {
|
|
const aScore = (a.counts.both + a.counts.pulse_only) > 0 ? 1 : 0;
|
|
const bScore = (b.counts.both + b.counts.pulse_only) > 0 ? 1 : 0;
|
|
if (bScore !== aScore) return bScore - aScore;
|
|
return (a.org_name ?? '').localeCompare(b.org_name ?? '');
|
|
});
|
|
}, [data, filter]);
|
|
|
|
const filteredTotal = data?.matches.filter(m => filter === 'all' || m.status === filter).length ?? 0;
|
|
|
|
return (
|
|
<div className="container mx-auto px-6 py-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<GitCompare className="h-6 w-6" />
|
|
Veeam RPO — Shadow vs Datto/AT
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
What Pulse <em>would</em> ticket (shadow mode) vs what Datto RMM actually created in Autotask.
|
|
Click <Sparkles className="h-3 w-3 inline text-blue-500" /> Analyze on any closed ticket to evaluate suppression accuracy.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{PERIODS.map(p => (
|
|
<Button key={p.value} variant={period === p.value ? 'default' : 'outline'} size="sm" onClick={() => setPeriod(p.value)}>
|
|
{p.label}
|
|
</Button>
|
|
))}
|
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading && !data ? (
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
|
|
</div>
|
|
) : data && (
|
|
<>
|
|
{/* Summary cards */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
{([
|
|
{ key: 'both', label: 'Both Agree', icon: CheckCircle2, iconCls: 'text-blue-500', value: data.summary.both, sub: 'Pulse + Datto both flagged', valCls: '' },
|
|
{ key: 'pulse_only', label: 'Pulse Only', icon: AlertTriangle,iconCls: 'text-destructive', value: data.summary.pulse_only, sub: 'No AT ticket from Datto', valCls: 'text-destructive' },
|
|
{ key: 'datto_only', label: 'Datto / AT Only', icon: Ticket, iconCls: 'text-orange-500', value: data.summary.datto_only, sub: `${data.summary.datto_at_total} total · ${data.summary.datto_at_open} open`, valCls: 'text-orange-500' },
|
|
{ key: 'offline_suppressed', label: 'Offline Suppressed',icon: WifiOff, iconCls: 'text-muted-foreground',value: data.summary.offline_suppressed, sub: 'Device offline — suppressed', valCls: '' },
|
|
] as const).map(({ key, label, icon: Icon, iconCls, value, sub, valCls }) => (
|
|
<Card key={key} className="cursor-pointer hover:bg-muted/30" onClick={() => setFilter(key as any)}>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
|
<Icon className={`h-4 w-4 ${iconCls}`} />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className={`text-2xl font-bold ${valCls}`}>{value}</div>
|
|
<p className="text-xs text-muted-foreground">{sub}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<Tabs value={filter} onValueChange={v => setFilter(v as any)}>
|
|
<TabsList>
|
|
<TabsTrigger value="all">
|
|
All <Badge variant="secondary" className="ml-1.5 h-4 px-1 text-[10px]">{data.matches.length}</Badge>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="both">Both ({data.summary.both})</TabsTrigger>
|
|
<TabsTrigger value="pulse_only">Pulse Only ({data.summary.pulse_only})</TabsTrigger>
|
|
<TabsTrigger value="datto_only">Datto/AT ({data.summary.datto_only})</TabsTrigger>
|
|
<TabsTrigger value="offline_suppressed">Offline ({data.summary.offline_suppressed})</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value={filter} className="mt-4">
|
|
<div className="rounded-md border overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th className="px-3 py-2.5 text-left font-medium text-xs w-44">Device</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-xs w-36">Match</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-xs">Pulse Shadow</th>
|
|
<th className="px-3 py-2.5 text-left font-medium text-xs">Autotask Tickets</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groups.length > 0 ? groups.map(group => (
|
|
<ClientGroupRow
|
|
key={group.org_name ?? 'unknown'}
|
|
group={group}
|
|
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
|
|
/>
|
|
)) : (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
|
{data.matches.length === 0
|
|
? 'No data yet — RPO check must run at least once.'
|
|
: 'No rows match this filter.'}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{groups.length > 0 && (
|
|
<p className="text-xs text-muted-foreground mt-2 pl-1">
|
|
{groups.length} client{groups.length !== 1 ? 's' : ''} · {filteredTotal} device{filteredTotal !== 1 ? 's' : ''}
|
|
</p>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|