wulf-pulse/app/analyzer/reports/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

152 lines
5.5 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
interface ReportSummary {
id: string;
generatedAt: string;
reportTitle: string | null;
ticketCount: number;
status: 'pending' | 'running' | 'complete' | 'failed';
estimatedCostUsd: number | null;
modelUsed: string | null;
generatedByUserId: string | null;
}
export default function ReportsListPage() {
const [reports, setReports] = useState<ReportSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch('/api/analyzer/aggregate-reports')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.then((data: { reports: ReportSummary[] }) => {
if (!cancelled) setReports(data.reports);
})
.catch((err) => {
if (!cancelled)
setError(err instanceof Error ? err.message : 'Failed to load reports');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="container mx-auto px-4 py-6 space-y-6 max-w-screen-xl">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Aggregate reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
Cross-ticket pattern analysis. Generate a new one from the browse
page.
</p>
</div>
<Button asChild>
<Link href="/analyzer/tickets">Browse tickets</Link>
</Button>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-6 text-sm text-muted-foreground">Loading</div>
) : error ? (
<div className="p-6 text-sm text-destructive">{error}</div>
) : reports.length === 0 ? (
<div className="p-12 text-center text-muted-foreground">
<p>No reports yet.</p>
<p className="text-xs mt-1">
Pick tickets on the browse page and click{' '}
<em>Generate aggregate report</em>.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]">Status</TableHead>
<TableHead>Title</TableHead>
<TableHead className="w-[100px]">Tickets</TableHead>
<TableHead className="w-[120px]">Model</TableHead>
<TableHead className="w-[100px]">Cost</TableHead>
<TableHead className="w-[160px]">Generated</TableHead>
<TableHead className="w-[100px] text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((r) => (
<TableRow key={r.id}>
<TableCell>
{r.status === 'complete' ? (
<Badge
variant="outline"
className="bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
>
<CheckCircle2 className="w-3 h-3 mr-1" />
Complete
</Badge>
) : r.status === 'failed' ? (
<Badge variant="destructive">
<XCircle className="w-3 h-3 mr-1" />
Failed
</Badge>
) : (
<Badge variant="secondary">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{r.status}
</Badge>
)}
</TableCell>
<TableCell className="text-sm">
{r.reportTitle ?? (
<span className="text-muted-foreground italic">Untitled</span>
)}
</TableCell>
<TableCell className="text-sm">{r.ticketCount}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{r.modelUsed ?? '—'}
</TableCell>
<TableCell className="text-xs">
{r.estimatedCostUsd === null
? '—'
: `$${r.estimatedCostUsd.toFixed(4)}`}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(r.generatedAt).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" asChild>
<Link href={`/analyzer/reports/${r.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}