wulf-pulse/app/analyzer/reports/[id]/page.tsx
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:

2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
    aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
    model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
    or failure. Worker persists a status='failed' analyzer_analyses row when
    the pipeline throws so partial stage records have a parent. Pipeline
    exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
    phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
    New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
    headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
    scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
    legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
    queue/status/priority/assignee, sticky filter bar, active-filter chips,
    bulk selection persisted via localStorage, "Analyze N selected" +
    "Generate aggregate report" actions. New <MultiSelect> primitive.
    Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
    SQL distributions immediately so UI shows partial state during the
    Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
    /:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
    $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
    override. Every gating decision audited.
2.8 Runbook + build notes updated.

128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00

489 lines
17 KiB
TypeScript
Raw 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 { useEffect, useState, use } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ArrowLeft,
AlertTriangle,
Loader2,
CheckCircle2,
XCircle,
} from 'lucide-react';
import { AnalysisMarkdown } from '@/components/analyzer/analysis-markdown';
type Report = {
id: string;
generatedAt: string;
reportTitle: string | null;
ticketCount: number;
status: 'pending' | 'running' | 'complete' | 'failed';
errorMessage: string | null;
estimatedCostUsd: number | null;
modelUsed: string | null;
totalInputTokens: number | null;
totalOutputTokens: number | null;
itglueContextIncluded: boolean | null;
dateRangeActual: { earliest: string | null; latest: string | null } | null;
categoryDistribution: Record<string, number> | null;
clientDistribution: Record<string, number> | null;
resolutionPathDistribution: Record<string, number> | null;
rootCauseDistribution: Record<string, number> | null;
documentationGaps:
| Array<{
gap: string;
frequency: number;
example_ticket_numbers: string[];
evidence: string;
itglue_check: 'no_doc_exists' | 'doc_exists_but_unused' | 'unable_to_verify';
}>
| null;
processGaps:
| Array<{
gap: string;
frequency: number;
severity: 'low' | 'medium' | 'high';
example_ticket_numbers: string[];
evidence: string;
}>
| null;
clientPatterns:
| Array<{
client: string;
pattern: string;
frequency: number;
example_ticket_numbers: string[];
}>
| null;
recurrenceClusters:
| Array<{ theme: string; ticket_numbers: string[]; summary: string }>
| null;
systemicObservations:
| Array<{ observation: string; evidence: string; severity: 'low' | 'medium' | 'high' }>
| null;
recommendedActions:
| Array<{
action: string;
rationale: string;
priority: 'low' | 'medium' | 'high';
type: 'documentation' | 'process' | 'training' | 'tooling';
}>
| null;
narrativeSummary: string | null;
executiveSummary: string | null;
};
const SEVERITY_TONE: Record<string, string> = {
high: 'border-red-500 bg-red-500/5',
medium: 'border-amber-500 bg-amber-500/5',
low: 'border-blue-500 bg-blue-500/5',
};
const ITGLUE_CHECK_TONE: Record<string, string> = {
no_doc_exists: 'text-red-600 border-red-300 dark:text-red-400 dark:border-red-800',
doc_exists_but_unused:
'text-amber-600 border-amber-300 dark:text-amber-400 dark:border-amber-800',
unable_to_verify: 'text-muted-foreground',
};
const ITGLUE_CHECK_LABEL: Record<string, string> = {
no_doc_exists: 'No doc exists',
doc_exists_but_unused: 'Doc exists but unused',
unable_to_verify: 'Unable to verify',
};
function MiniBars({ data }: { data: Record<string, number> | null }) {
if (!data || Object.keys(data).length === 0) return null;
const entries = Object.entries(data).sort((a, b) => b[1] - a[1]);
const max = entries[0]?.[1] ?? 1;
return (
<div className="space-y-1.5">
{entries.map(([key, val]) => (
<div key={key} className="text-xs">
<div className="flex justify-between">
<span className="truncate">{key}</span>
<span className="text-muted-foreground tabular-nums ml-2">{val}</span>
</div>
<div className="h-1.5 bg-muted rounded overflow-hidden">
<div
className="h-full bg-primary rounded"
style={{ width: `${Math.round((val / max) * 100)}%` }}
/>
</div>
</div>
))}
</div>
);
}
export default function AggregateReportPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [report, setReport] = useState<Report | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
async function load() {
try {
const res = await fetch(`/api/analyzer/aggregate-reports/${id}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const { report } = (await res.json()) as { report: Report };
if (cancelled) return;
setReport(report);
setLoading(false);
if (report.status === 'pending' || report.status === 'running') {
timer = setTimeout(load, 3000);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
void load();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [id]);
if (loading && !report)
return <div className="p-6 text-sm text-muted-foreground">Loading</div>;
if (error)
return <div className="p-6 text-sm text-destructive">{error}</div>;
if (!report) return null;
return (
<div className="container mx-auto px-4 py-6 space-y-6 max-w-screen-xl">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/analyzer/reports">
<ArrowLeft className="w-4 h-4 mr-1" />
All reports
</Link>
</Button>
</div>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1 min-w-0">
<CardTitle className="text-2xl">
{report.reportTitle ?? `Aggregate report · ${report.ticketCount} tickets`}
</CardTitle>
<p className="text-sm text-muted-foreground">
{new Date(report.generatedAt).toLocaleString()}
{report.modelUsed && ` · ${report.modelUsed}`}
{report.estimatedCostUsd !== null && (
<> · ${report.estimatedCostUsd.toFixed(4)}</>
)}
{report.itglueContextIncluded !== null && (
<>
{' · '}IT Glue context: {report.itglueContextIncluded ? 'included' : 'no'}
</>
)}
{report.dateRangeActual?.earliest && report.dateRangeActual?.latest && (
<>
{' · '}
{new Date(report.dateRangeActual.earliest).toLocaleDateString()}
{' '}
{new Date(report.dateRangeActual.latest).toLocaleDateString()}
</>
)}
</p>
</div>
<StatusBadge status={report.status} />
</div>
</CardHeader>
</Card>
{report.status === 'failed' && report.errorMessage && (
<Card className="border-destructive/40 bg-destructive/5">
<CardContent className="pt-6 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium">Report generation failed</p>
<p className="text-sm text-muted-foreground mt-1 font-mono whitespace-pre-wrap">
{report.errorMessage}
</p>
</div>
</CardContent>
</Card>
)}
{(report.status === 'pending' || report.status === 'running') && (
<Card className="border-primary/40 bg-primary/5">
<CardContent className="pt-6 flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="text-sm">
{report.status === 'pending' ? 'Queued.' : 'Running.'} The page will
refresh automatically when the report is ready (typically 3090s).
</div>
</CardContent>
</Card>
)}
{/* Executive summary */}
{report.executiveSummary && (
<Card className="border-primary/40">
<CardHeader>
<CardTitle className="text-base text-primary uppercase tracking-wide font-semibold">
Executive Summary
</CardTitle>
</CardHeader>
<CardContent>
<AnalysisMarkdown className="text-base">
{report.executiveSummary}
</AnalysisMarkdown>
</CardContent>
</Card>
)}
{/* Distributions */}
{(report.categoryDistribution ||
report.rootCauseDistribution ||
report.resolutionPathDistribution ||
report.clientDistribution) && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">Categories</CardTitle>
</CardHeader>
<CardContent>
<MiniBars data={report.categoryDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">Root cause</CardTitle>
</CardHeader>
<CardContent>
<MiniBars data={report.rootCauseDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">Resolution</CardTitle>
</CardHeader>
<CardContent>
<MiniBars data={report.resolutionPathDistribution} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">Clients</CardTitle>
</CardHeader>
<CardContent>
<MiniBars data={report.clientDistribution} />
</CardContent>
</Card>
</div>
)}
{/* Documentation gaps */}
{report.documentationGaps && report.documentationGaps.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Documentation gaps</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{report.documentationGaps.map((g, i) => (
<div
key={i}
className="rounded-lg border-l-4 border-blue-400 bg-blue-500/5 p-3"
>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
className={ITGLUE_CHECK_TONE[g.itglue_check] ?? ''}
>
{ITGLUE_CHECK_LABEL[g.itglue_check]}
</Badge>
<Badge variant="secondary">{g.frequency}×</Badge>
<p className="text-sm font-medium">{g.gap}</p>
</div>
<p className="text-xs text-muted-foreground mt-2">{g.evidence}</p>
<div className="mt-2 text-xs flex flex-wrap gap-1">
{g.example_ticket_numbers.map((tn) => (
<Link
key={tn}
href={`/analyzer/ticket/${encodeURIComponent(tn)}`}
className="font-mono underline text-muted-foreground hover:text-foreground"
>
{tn}
</Link>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Process gaps */}
{report.processGaps && report.processGaps.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Process gaps</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{report.processGaps.map((g, i) => (
<div
key={i}
className={`rounded-lg border-l-4 p-3 ${SEVERITY_TONE[g.severity] ?? ''}`}
>
<div className="flex items-center gap-2 flex-wrap">
<Badge
variant={
g.severity === 'high'
? 'destructive'
: g.severity === 'medium'
? 'default'
: 'secondary'
}
>
{g.severity}
</Badge>
<Badge variant="secondary">{g.frequency}×</Badge>
<p className="text-sm font-medium">{g.gap}</p>
</div>
<p className="text-xs text-muted-foreground mt-2">{g.evidence}</p>
<div className="mt-2 text-xs flex flex-wrap gap-1">
{g.example_ticket_numbers.map((tn) => (
<Link
key={tn}
href={`/analyzer/ticket/${encodeURIComponent(tn)}`}
className="font-mono underline text-muted-foreground hover:text-foreground"
>
{tn}
</Link>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Recurrence clusters */}
{report.recurrenceClusters && report.recurrenceClusters.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Recurrence clusters</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{report.recurrenceClusters.map((c, i) => (
<div key={i} className="border rounded-lg p-4">
<div className="font-medium text-sm mb-2">{c.theme}</div>
<AnalysisMarkdown className="text-sm">{c.summary}</AnalysisMarkdown>
<div className="mt-2 text-xs flex flex-wrap gap-1">
{c.ticket_numbers.map((tn) => (
<Link
key={tn}
href={`/analyzer/ticket/${encodeURIComponent(tn)}`}
className="font-mono underline text-muted-foreground hover:text-foreground"
>
{tn}
</Link>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Recommended actions */}
{report.recommendedActions && report.recommendedActions.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Recommended actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
{report.recommendedActions
.slice()
.sort(
(a, b) =>
['low', 'medium', 'high'].indexOf(b.priority) -
['low', 'medium', 'high'].indexOf(a.priority)
)
.map((a, i) => (
<div key={i} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Badge
variant={
a.priority === 'high'
? 'destructive'
: a.priority === 'medium'
? 'default'
: 'secondary'
}
>
{a.priority}
</Badge>
<Badge variant="outline" className="capitalize">
{a.type}
</Badge>
</div>
<AnalysisMarkdown className="text-sm prose-p:font-medium">
{a.action}
</AnalysisMarkdown>
<AnalysisMarkdown className="text-xs text-muted-foreground">
{a.rationale}
</AnalysisMarkdown>
</div>
))}
</CardContent>
</Card>
)}
{/* Narrative summary */}
{report.narrativeSummary && (
<Card>
<CardHeader>
<CardTitle>Narrative summary</CardTitle>
</CardHeader>
<CardContent>
<AnalysisMarkdown className="text-base">
{report.narrativeSummary}
</AnalysisMarkdown>
</CardContent>
</Card>
)}
</div>
);
}
function StatusBadge({ status }: { status: Report['status'] }) {
if (status === 'complete')
return (
<Badge className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400" variant="outline">
<CheckCircle2 className="w-3 h-3 mr-1" />
Complete
</Badge>
);
if (status === 'failed')
return (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
Failed
</Badge>
);
return (
<Badge variant="secondary">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{status === 'pending' ? 'Queued' : 'Running'}
</Badge>
);
}