wulf-pulse/app/veeam-analysis/page.tsx
lorentz 91b876310e feat(07.1-05): user-tz on dashboard, quotes, veeam-analysis
- dashboard/page.tsx: thread tz into PageHeader description's
  toLocaleDateString call.
- quotes/page.tsx: thread tz into formatDate arrow helper inside the
  default export.
- veeam-analysis/page.tsx: thread tz into the summary footer's
  generated-at toLocaleString call.

Migrates 3 of 81 audit leak callsites.
2026-05-07 08:36:09 -04:00

723 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import DataTable, { type Column } from '@/components/admin/DataTable';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import {
Brain, Play, RefreshCw, CheckCircle2, AlertTriangle, Clock,
WifiOff, ChevronDown, ChevronRight, Wrench, RotateCcw, Sparkles, Zap, BookOpen, GraduationCap,
} from 'lucide-react';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
// ── Types ─────────────────────────────────────────────────────────────────────
interface Stats {
total_analyzed: string;
total_ytd: string;
avg_hours: string;
same_day_pct: string;
preventable_pct: string;
offline_pct: string;
auto_resolved_pct: string;
}
interface CategoryRow { problem_category: string; count: string; avg_hours: string; same_day_pct: string }
interface ResolutionRow { resolution_type: string; count: string }
interface SkillRow { skill: string; count: string }
interface ComplexityRow { complexity: string; count: string }
interface AnalysisData {
stats: Stats;
by_category: CategoryRow[];
by_resolution: ResolutionRow[];
skills: SkillRow[];
by_complexity: ComplexityRow[];
tickets: TicketRow[];
total: number;
limit: number;
page: number;
}
interface TicketRow {
ticket_number: string;
company_name: string | null;
device_hostname: string | null;
ticket_created_at: string | null;
ticket_closed_at: string | null;
same_day_close: boolean;
hours_worked: string;
problem_category: string;
resolution_type: string;
complexity: string;
device_was_offline: boolean | null;
backup_completed_before_tech: boolean | null;
preventable: boolean | null;
work_summary: string | null;
recommended_procedure: string | null;
}
interface RunStatus {
is_running: boolean;
run_total: number;
run_done: number;
run_errors: number;
total_analyzed: number;
total_eligible: number;
last_analyzed_at: string | null;
}
// ── Config ────────────────────────────────────────────────────────────────────
const CATEGORY_CFG: Record<string, { label: string; color: string }> = {
device_offline: { label: 'Device Offline', color: '#94a3b8' },
agent_issue: { label: 'Agent Issue', color: '#f97316' },
job_failed: { label: 'Job Failed', color: '#ef4444' },
storage_issue: { label: 'Storage Issue', color: '#f59e0b' },
network_issue: { label: 'Network Issue', color: '#3b82f6' },
authentication: { label: 'Authentication', color: '#8b5cf6' },
software_error: { label: 'Software Error', color: '#ec4899' },
self_resolved: { label: 'Self-Resolved', color: '#22c55e' },
configuration: { label: 'Configuration', color: '#06b6d4' },
other: { label: 'Other', color: '#6b7280' },
};
const RESOLUTION_CFG: Record<string, string> = {
no_action_needed: 'No Action Needed',
device_powered_on: 'Device Powered On',
backup_restarted: 'Backup Restarted',
agent_reinstalled: 'Agent Reinstalled',
storage_cleared: 'Storage Cleared',
settings_updated: 'Settings Updated',
escalated: 'Escalated',
other: 'Other',
};
const COMPLEXITY_COLOR: Record<string, string> = {
trivial: '#22c55e',
low: '#84cc16',
medium: '#f59e0b',
high: '#ef4444',
};
interface SummaryAnalysis {
headline: string;
key_findings: string[];
issue_breakdown: { category: string; insight: string; sop: string }[];
skills_assessment: string;
quick_wins: string[];
automation_opportunities: string;
training_priority: string;
}
interface SummaryData {
analysis: SummaryAnalysis;
model: string;
generated_at: string;
}
function catLabel(k: string) { return CATEGORY_CFG[k]?.label ?? k; }
function catColor(k: string) { return CATEGORY_CFG[k]?.color ?? '#6b7280'; }
function resLabel(k: string) { return RESOLUTION_CFG[k] ?? k; }
function timeAgo(d: string | null) {
if (!d) return '—';
const h = Math.floor((Date.now() - new Date(d).getTime()) / 3_600_000);
if (h < 1) return 'Just now';
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// ── Column defs + sub-row renderer ────────────────────────────────────────────
const TICKET_COLUMNS: Column<TicketRow>[] = [
{
key: 'ticket_number',
label: 'Ticket',
render: (v) => <span className="num font-medium">{v}</span>,
},
{
key: 'company_name',
label: 'Client',
render: (v) => <span className="max-w-[140px] truncate inline-block">{v ?? '—'}</span>,
},
{
key: 'device_hostname',
label: 'Device',
render: (v) => <span className="num text-[11px]">{v ?? '—'}</span>,
},
{
key: 'problem_category',
label: 'Category',
render: (v) => {
const cfg = CATEGORY_CFG[v as string];
return (
<Badge
variant="outline"
style={{ borderColor: cfg?.color, color: cfg?.color }}
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
>
{catLabel(v as string)}
</Badge>
);
},
},
{
key: 'resolution_type',
label: 'Resolution',
render: (v) => <span className="text-muted-foreground">{resLabel(v as string)}</span>,
},
{
key: 'same_day_close',
label: 'Same-day',
render: (v) =>
v ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
key: 'hours_worked',
label: 'Hours',
render: (v) => <span className="num">{parseFloat(v as string).toFixed(2)}h</span>,
},
{
key: 'complexity',
label: 'Complexity',
render: (v) => (
<Badge
variant="outline"
style={{
borderColor: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
color: COMPLEXITY_COLOR[v as string] ?? '#6b7280',
}}
className="text-[10px] h-4 px-1.5"
>
{v}
</Badge>
),
},
{
key: 'ticket_created_at',
label: 'Age',
render: (v) => <span className="text-muted-foreground num">{timeAgo(v as string)}</span>,
},
];
function renderTicketSubRow(t: TicketRow) {
return (
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
{t.work_summary && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Summary</span>
<p className="mt-0.5">{t.work_summary}</p>
</div>
)}
<div className="flex flex-wrap gap-3 text-muted-foreground">
{t.device_was_offline != null && (
<span className="flex items-center gap-1">
<WifiOff className="h-3 w-3" />
{t.device_was_offline ? 'Device was offline' : 'Device was online'}
</span>
)}
{t.backup_completed_before_tech != null && (
<span className="flex items-center gap-1">
{t.backup_completed_before_tech ? (
<>
<CheckCircle2 className="h-3 w-3 text-emerald-500" /> Backup auto-completed
</>
) : (
<>
<Wrench className="h-3 w-3" /> Tech action required
</>
)}
</span>
)}
{t.preventable != null && (
<span className={t.preventable ? 'text-amber-500' : ''}>
{t.preventable ? '⚠ Preventable' : '✓ Not preventable'}
</span>
)}
</div>
</div>
{t.recommended_procedure && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Recommended SOP</span>
<p className="mt-0.5 text-muted-foreground italic">{t.recommended_procedure}</p>
</div>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamAnalysisPage() {
const tz = useUserTimezone();
const [data, setData] = useState<AnalysisData | null>(null);
const [status, setStatus] = useState<RunStatus | null>(null);
const [loading, setLoading] = useState(true);
const [catFilter, setCatFilter] = useState('');
const [page, setPage] = useState(1);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchData = useCallback(async (cat = catFilter, p = page) => {
const params = new URLSearchParams({ page: String(p) });
if (cat) params.set('category', cat);
const res = await fetch(`/api/veeam/ticket-analysis?${params}`);
setData(await res.json());
setLoading(false);
}, [catFilter, page]);
const fetchStatus = useCallback(async () => {
const res = await fetch('/api/veeam/ticket-analysis/status');
const s: RunStatus = await res.json();
setStatus(s);
return s;
}, []);
useEffect(() => { fetchData(); fetchStatus(); }, []);
const startPolling = useCallback(() => {
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
const s = await fetchStatus();
if (!s.is_running) {
clearInterval(pollRef.current!);
pollRef.current = null;
fetchData();
}
}, 2000);
}, [fetchStatus, fetchData]);
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const handleRun = async (reanalyze = false) => {
const res = await fetch('/api/veeam/ticket-analysis/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reanalyze }),
});
const json = await res.json();
if (json.started) {
await fetchStatus();
startPolling();
}
};
const handleSummary = async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const res = await fetch('/api/veeam/ticket-analysis/summary', { method: 'POST' });
const json = await res.json();
if (json.error) setSummaryError(json.raw ? `${json.error}\n\n${json.raw}` : json.error);
else setSummary(json);
} catch (e: any) {
setSummaryError(e.message);
} finally {
setSummaryLoading(false);
}
};
const handleCatFilter = (cat: string) => {
const next = catFilter === cat ? '' : cat;
setCatFilter(next);
setPage(1);
setLoading(true);
fetchData(next, 1);
};
const totalPages = data ? Math.ceil(data.total / (data.limit ?? 50)) : 1;
const runPct = status?.is_running && status.run_total > 0
? Math.round(100 * status.run_done / status.run_total)
: null;
return (
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Brain className="h-6 w-6" />
Veeam Backup Ticket Analysis
</h1>
<p className="text-sm text-muted-foreground mt-1">
AI classification of YTD Veeam tickets to identify failure patterns, required skills, and SOP gaps.
</p>
</div>
<div className="flex items-center gap-2">
{status && !status.is_running && status.total_analyzed > 0 && (
<Button variant="outline" size="sm" onClick={() => handleRun(true)}>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Re-analyze all
</Button>
)}
<Button
size="sm"
onClick={() => handleRun(false)}
disabled={status?.is_running}
>
{status?.is_running
? <><RefreshCw className="h-3.5 w-3.5 mr-1 animate-spin" />Running</>
: <><Play className="h-3.5 w-3.5 mr-1" />
{status && status.total_analyzed < status.total_eligible ? 'Continue Analysis' : 'Run Analysis'}
</>}
</Button>
</div>
</div>
{/* Progress bar */}
{status?.is_running && runPct !== null && (
<div className="space-y-1">
<Progress value={runPct} className="h-2" />
<p className="text-xs text-muted-foreground">
{status.run_done} / {status.run_total} tickets analyzed
{status.run_errors > 0 && ` · ${status.run_errors} errors`}
</p>
</div>
)}
{/* Status line */}
{status && !status.is_running && (
<p className="text-xs text-muted-foreground -mt-4">
{status.total_analyzed} of {status.total_eligible} eligible tickets analyzed
{status.last_analyzed_at && ` · Last run ${timeAgo(status.last_analyzed_at)}`}
{status.total_eligible === 0 && ' — no Veeam tickets with time entries found YTD'}
</p>
)}
{status?.total_analyzed === 0 && !status.is_running ? (
<div className="rounded-md border border-dashed py-16 text-center text-muted-foreground">
<Brain className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No analysis data yet.</p>
<p className="text-xs mt-1">Click <strong>Run Analysis</strong> to classify {status.total_eligible} tickets with time entries.</p>
</div>
) : (
<>
{/* Summary cards */}
{loading && !data ? (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : data && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{
label: 'Tickets Analyzed',
value: data.stats.total_analyzed,
sub: `of ${data.stats.total_ytd} YTD`,
icon: Brain,
cls: '',
},
{
label: 'Same-Day Close',
value: `${data.stats.same_day_pct ?? 0}%`,
sub: 'Opened & closed same day',
icon: CheckCircle2,
cls: 'text-green-500',
},
{
label: 'Avg Hours/Ticket',
value: `${data.stats.avg_hours ?? 0}h`,
sub: `${data.stats.auto_resolved_pct ?? 0}% auto-resolved before tech`,
icon: Clock,
cls: '',
},
{
label: 'Preventable',
value: `${data.stats.preventable_pct ?? 0}%`,
sub: `${data.stats.offline_pct ?? 0}% device was offline`,
icon: AlertTriangle,
cls: 'text-amber-500',
},
].map(({ label, value, sub, icon: Icon, cls }) => (
<Card key={label}>
<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 ${cls || 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${cls}`}>{value}</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</CardContent>
</Card>
))}
</div>
)}
{/* Charts row */}
{data && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Problem categories */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Problem Categories</CardTitle>
<p className="text-xs text-muted-foreground">Click a bar to filter the ticket list</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={data.by_category.map(r => ({
name: catLabel(r.problem_category),
key: r.problem_category,
count: parseInt(r.count),
avg_hours: parseFloat(r.avg_hours),
}))}
layout="vertical"
margin={{ left: 10, right: 20, top: 0, bottom: 0 }}
>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={110} />
<Tooltip
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="count" radius={[0, 3, 3, 0]} onClick={(d) => handleCatFilter(String(d.key ?? ''))}>
{data.by_category.map((r, i) => (
<Cell
key={i}
fill={catColor(r.problem_category)}
opacity={catFilter && catFilter !== r.problem_category ? 0.3 : 1}
cursor="pointer"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Right column: Resolution + Skills */}
<div className="space-y-4">
{/* Resolution types */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Resolution Types</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{data.by_resolution.map(r => {
const total = data.by_resolution.reduce((s, x) => s + parseInt(x.count), 0);
const pct = total > 0 ? Math.round(100 * parseInt(r.count) / total) : 0;
return (
<div key={r.resolution_type} className="flex items-center gap-2 text-xs">
<span className="w-36 text-muted-foreground truncate">{resLabel(r.resolution_type)}</span>
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-primary h-1.5 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-medium">{r.count}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Skills Required</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1.5">
{data.skills.map(s => (
<Badge key={s.skill} variant="secondary" className="text-[11px]">
{s.skill}
<span className="ml-1.5 text-muted-foreground">{s.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Sonnet Summary */}
{data && parseInt(data.stats.total_analyzed) > 0 && (
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-500" />
Operations Summary
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Sonnet analysis of aggregate patterns issue types, skills, and SOPs
</p>
</div>
<Button
variant={summary ? 'outline' : 'default'}
size="sm"
onClick={handleSummary}
disabled={summaryLoading}
>
{summaryLoading
? <><RefreshCw className="h-3.5 w-3.5 mr-1.5 animate-spin" />Analyzing</>
: summary
? <><RotateCcw className="h-3.5 w-3.5 mr-1.5" />Refresh</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" />Generate Summary</>}
</Button>
</CardHeader>
{summaryError && (
<CardContent>
<p className="text-sm text-destructive">{summaryError}</p>
</CardContent>
)}
{summaryLoading && !summary && (
<CardContent className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-full" />
</CardContent>
)}
{summary && (
<CardContent className="space-y-6 pt-0">
{/* Headline */}
<p className="text-sm font-medium">{summary.analysis.headline}</p>
{/* Key findings */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Key Findings
</h3>
<ul className="space-y-1">
{summary.analysis.key_findings?.map((f, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
{/* Issue breakdown */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Issue Breakdown &amp; SOPs
</h3>
<div className="space-y-3">
{summary.analysis.issue_breakdown?.map((item, i) => (
<div key={i} className="rounded-md border px-4 py-3 space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
style={{ borderColor: catColor(item.category), color: catColor(item.category) }}
className="text-[10px] h-4 px-1.5"
>
{catLabel(item.category)}
</Badge>
</div>
<p className="text-sm">{item.insight}</p>
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/40 rounded px-2.5 py-1.5">
<BookOpen className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span><span className="font-medium text-foreground">SOP:</span> {item.sop}</span>
</div>
</div>
))}
</div>
</div>
{/* Bottom row: skills + quick wins + automation */}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />Skills Assessment
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.skills_assessment}</p>
{summary.analysis.training_priority && (
<p className="text-xs border-l-2 border-blue-500/50 pl-2 text-muted-foreground italic">
{summary.analysis.training_priority}
</p>
)}
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5" />Quick Wins
</h3>
<ul className="space-y-1">
{summary.analysis.quick_wins?.map((w, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{w}</span>
</li>
))}
</ul>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3.5 w-3.5" />Automation Opportunities
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.automation_opportunities}</p>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Generated by {summary.model} · {new Date(summary.generated_at).toLocaleString(undefined, { timeZone: tz })}
</p>
</CardContent>
)}
</Card>
)}
{/* Ticket table */}
{data && (
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium">
Tickets
{catFilter && (
<Badge variant="secondary" className="ml-2 text-[10px]">
{catLabel(catFilter)}
<button onClick={() => handleCatFilter(catFilter)} className="ml-1 hover:text-destructive">×</button>
</Badge>
)}
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">{data.total} tickets</p>
</div>
<Button variant="ghost" size="sm" onClick={() => fetchData()}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="px-0 pb-3">
<DataTable<TicketRow>
columns={TICKET_COLUMNS}
data={data.tickets}
totalCount={data.total}
page={page}
pageSize={data.limit ?? 50}
onPageChange={(next) => {
setPage(next);
fetchData(catFilter, next);
}}
emptyTitle="No analyzed tickets yet"
emptyDescription="Run the analysis above to populate this list."
getRowCanExpand={() => true}
renderSubRow={renderTicketSubRow}
/>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}