feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- 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
This commit is contained in:
parent
07067bef19
commit
ea3471d38d
36 changed files with 5604 additions and 217 deletions
575
app/veeam-comparison/page.tsx
Normal file
575
app/veeam-comparison/page.tsx
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue