wulf-pulse/app/veeam-comparison/page.tsx
lorentz 9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch.  Drops 2013-era
inline styles and consolidates patterns behind shared primitives.

Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
  the standards-guide blue (#0075AD) with utility classes for numerics
  (.num / .num-lg / .num-xl), metric labels, surface tints, and the
  wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
  Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
  "Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page

Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
  health table, worker pulse cards (analyzer / RMM / sync scheduler),
  token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
  to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
  integrations (e.g. SentinelOne) — no failure noise from broken-on-
  purpose entries.  Aliases supported (sentinelone → s1, etc.)

Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
  total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
  area chart, 30-day mean resolution time line chart, today's active
  engineers leaderboard

Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
  status, classification, source, company type, publish, active /
  yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)

Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
  PageHeader rule (consistent across flat links and submenu triggers);
  active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config

Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs

DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
  unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow

Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
  collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below

Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
  workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
  rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
  INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:33:13 -04:00

583 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
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 */}
<TableRow
className="bg-muted/30 cursor-pointer select-none"
onClick={() => setOpen(o => !o)}
>
{/* Device col: chevron + org name */}
<TableCell className="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>
</TableCell>
{/* Match col: status pills */}
<TableCell className="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>
</TableCell>
{/* Pulse shadow col: total devices flagged */}
<TableCell className="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>}
</TableCell>
{/* AT tickets col: ticket count */}
<TableCell className="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>}
</TableCell>
</TableRow>
{/* Device detail rows */}
{open && group.rows.map((row) => {
const cfg = STATUS_CONFIG[row.status];
return (
<TableRow key={row.key} className={`align-top text-xs ${cfg.rowAccent}`}>
<TableCell className="pl-9 num font-medium w-44 text-[11px]">
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
</TableCell>
<TableCell className="w-36">
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
</TableCell>
<TableCell className="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>
)}
</TableCell>
<TableCell>
<AtTicketCell
tickets={row.at_tickets}
ticketCount={row.at_ticket_count}
hostname={row.hostname}
orgName={row.org_name}
hoursOffline={row.offline?.hours_offline}
/>
</TableCell>
</TableRow>
);
})}
</>
);
}
// ── 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>
<TableHeader className="bg-muted/50">
<TableRow>
<TableHead className="text-xs w-44">Device</TableHead>
<TableHead className="text-xs w-36">Match</TableHead>
<TableHead className="text-xs">Pulse Shadow</TableHead>
<TableHead className="text-xs">Autotask Tickets</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.length > 0 ? groups.map(group => (
<ClientGroupRow
key={group.org_name ?? 'unknown'}
group={group}
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
/>
)) : (
<TableRow>
<TableCell 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.'}
</TableCell>
</TableRow>
)}
</TableBody>
</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>
);
}