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>
152 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|