feat(dashboard): add review list and detail pages

This commit is contained in:
2025-05-03 11:14:43 +00:00
parent 46e9b4adca
commit 2041d23751
6 changed files with 587 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import type { Finding } from '../../types/api';
import { SeverityBadge } from '../common/SeverityBadge';
interface FindingCardProps {
finding: Finding;
}
const agentColors: Record<string, string> = {
security: 'text-red-600 bg-red-50',
style: 'text-blue-600 bg-blue-50',
complexity: 'text-purple-600 bg-purple-50',
};
export function FindingCard({ finding }: FindingCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-start gap-4 text-left hover:bg-gray-50 transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
<SeverityBadge severity={finding.severity} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900">
{finding.title}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded ${agentColors[finding.agent] || 'text-gray-600 bg-gray-50'}`}
>
{finding.agent}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="font-mono">
{finding.file}:{finding.line_start}
{finding.line_end !== finding.line_start &&
`-${finding.line_end}`}
</span>
<span>{Math.round(finding.confidence * 100)}% confidence</span>
</div>
</div>
<div className="flex-shrink-0">
<span className="text-gray-400">{isExpanded ? '▼' : '▶'}</span>
</div>
</button>
{isExpanded && (
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 space-y-3">
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
Description
</h4>
<p className="text-sm text-gray-700">{finding.description}</p>
</div>
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
Reasoning
</h4>
<p className="text-sm text-gray-700">{finding.reasoning}</p>
</div>
{finding.suggestion && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
Suggestion
</h4>
<p className="text-sm text-gray-700">{finding.suggestion}</p>
</div>
)}
{finding.references.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
References
</h4>
<ul className="text-sm space-y-1">
{finding.references.map((ref, index) => (
<li key={index}>
<a
href={ref}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800 hover:underline"
>
{ref}
</a>
</li>
))}
</ul>
</div>
)}
<div className="text-xs text-gray-400">
Prompt version: {finding.prompt_version}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Link } from 'react-router-dom';
import type { ReviewSummary } from '../../types/api';
import { VerdictBadge } from '../deliberation/VerdictBadge';
interface ReviewCardProps {
review: ReviewSummary;
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatCost(cost: number | null): string {
if (cost === null) return '-';
return `$${cost.toFixed(4)}`;
}
export function ReviewCard({ review }: ReviewCardProps) {
return (
<Link
to={`/reviews/${review.id}`}
className="block bg-white border border-gray-200 rounded-lg p-4 hover:border-indigo-300 hover:shadow-sm transition-all"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900 truncate">
{review.repository}
</span>
<span className="text-sm text-gray-500">#{review.pr_number}</span>
</div>
{review.pr_title && (
<p className="text-sm text-gray-600 truncate">{review.pr_title}</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
{review.author && <span>by {review.author}</span>}
<span>{formatDate(review.created_at)}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<VerdictBadge verdict={review.verdict} size="sm" />
<div className="flex items-center gap-3 text-xs">
<span className="text-gray-600">
{review.finding_count} findings
</span>
{review.critical_count > 0 && (
<span className="text-red-600 font-medium">
{review.critical_count} critical
</span>
)}
{review.high_count > 0 && (
<span className="text-orange-600 font-medium">
{review.high_count} high
</span>
)}
</div>
<span className="text-xs text-gray-500">
{formatCost(review.total_cost_usd)}
</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,110 @@
import type { ReviewStatus, Verdict, ReviewFilters as Filters } from '../../types/api';
interface ReviewFiltersProps {
filters: Filters;
onChange: (filters: Filters) => void;
}
const statusOptions: { value: ReviewStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'running', label: 'Running' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
];
const verdictOptions: { value: Verdict | ''; label: string }[] = [
{ value: '', label: 'All Verdicts' },
{ value: 'approve', label: 'Approved' },
{ value: 'request_changes', label: 'Changes Requested' },
{ value: 'comment', label: 'Comment' },
];
export function ReviewFilters({ filters, onChange }: ReviewFiltersProps) {
const handleChange = (key: keyof Filters, value: string) => {
onChange({
...filters,
[key]: value || undefined,
page: 1,
});
};
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label
htmlFor="repository"
className="block text-sm font-medium text-gray-700 mb-1"
>
Repository
</label>
<input
type="text"
id="repository"
value={filters.repository || ''}
onChange={(e) => handleChange('repository', e.target.value)}
placeholder="Filter by repository..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label
htmlFor="author"
className="block text-sm font-medium text-gray-700 mb-1"
>
Author
</label>
<input
type="text"
id="author"
value={filters.author || ''}
onChange={(e) => handleChange('author', e.target.value)}
placeholder="Filter by author..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label
htmlFor="status"
className="block text-sm font-medium text-gray-700 mb-1"
>
Status
</label>
<select
id="status"
value={filters.status || ''}
onChange={(e) => handleChange('status', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="verdict"
className="block text-sm font-medium text-gray-700 mb-1"
>
Verdict
</label>
<select
id="verdict"
value={filters.verdict || ''}
onChange={(e) => handleChange('verdict', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
{verdictOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import type { ReviewSummary } from '../../types/api';
import { ReviewCard } from './ReviewCard';
import { LoadingSpinner } from '../common/LoadingSpinner';
interface ReviewListProps {
reviews: ReviewSummary[];
isLoading: boolean;
error: Error | null;
}
export function ReviewList({ reviews, isLoading, error }: ReviewListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-red-800">Failed to load reviews</p>
<p className="text-sm text-red-600 mt-1">{error.message}</p>
</div>
);
}
if (reviews.length === 0) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-600">No reviews found</p>
<p className="text-sm text-gray-500 mt-1">
Try adjusting your filters or check back later
</p>
</div>
);
}
return (
<div className="space-y-3">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
}

View File

@@ -0,0 +1,214 @@
import { useParams, Link } from 'react-router-dom';
import { useReview, useDeliberationLog } from '../api/reviews';
import { VerdictBadge } from '../components/deliberation/VerdictBadge';
import { FindingCard } from '../components/reviews/FindingCard';
import { ConflictCard } from '../components/deliberation/ConflictCard';
import { DeliberationTimeline } from '../components/deliberation/DeliberationTimeline';
import { LoadingSpinner } from '../components/common/LoadingSpinner';
import type { Severity } from '../types/api';
const severityOrder: Severity[] = ['critical', 'high', 'medium', 'low', 'info'];
function formatDate(dateString: string | null): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatCost(cost: number | null): string {
if (cost === null) return '-';
return `$${cost.toFixed(4)}`;
}
export function ReviewDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: review, isLoading, error } = useReview(id!);
const { data: deliberation } = useDeliberationLog(id!);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="lg" />
</div>
);
}
if (error || !review) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-red-800">Failed to load review</p>
<p className="text-sm text-red-600 mt-1">{error?.message}</p>
<Link to="/" className="text-indigo-600 hover:underline mt-2 inline-block">
Back to reviews
</Link>
</div>
);
}
const findingsBySeverity = severityOrder.reduce(
(acc, severity) => {
const findings = review.findings.filter((f) => f.severity === severity);
if (findings.length > 0) {
acc[severity] = findings;
}
return acc;
},
{} as Record<Severity, typeof review.findings>,
);
return (
<div>
<div className="mb-6">
<Link
to="/"
className="text-sm text-indigo-600 hover:text-indigo-800 mb-2 inline-block"
>
&larr; Back to reviews
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">
{review.repository} #{review.pr_number}
</h1>
{review.pr_title && (
<p className="text-gray-600 mt-1">{review.pr_title}</p>
)}
</div>
<VerdictBadge
verdict={review.verdict}
confidence={review.verdict_confidence}
/>
</div>
</div>
{/* Meta Info */}
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Author</span>
<p className="font-medium">{review.author || '-'}</p>
</div>
<div>
<span className="text-gray-500">Status</span>
<p className="font-medium capitalize">{review.status}</p>
</div>
<div>
<span className="text-gray-500">Created</span>
<p className="font-medium">{formatDate(review.created_at)}</p>
</div>
<div>
<span className="text-gray-500">Completed</span>
<p className="font-medium">{formatDate(review.completed_at)}</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm mt-4 pt-4 border-t border-gray-100">
<div>
<span className="text-gray-500">Total Cost</span>
<p className="font-medium">{formatCost(review.total_cost_usd)}</p>
</div>
<div>
<span className="text-gray-500">Total Tokens</span>
<p className="font-medium">
{review.total_tokens?.toLocaleString() || '-'}
</p>
</div>
<div>
<span className="text-gray-500">Base SHA</span>
<p className="font-mono text-xs">{review.base_sha.slice(0, 7)}</p>
</div>
<div>
<span className="text-gray-500">Head SHA</span>
<p className="font-mono text-xs">{review.head_sha.slice(0, 7)}</p>
</div>
</div>
{review.cost_by_agent && Object.keys(review.cost_by_agent).length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<span className="text-gray-500 text-sm">Cost by Agent</span>
<div className="flex gap-4 mt-1">
{Object.entries(review.cost_by_agent).map(([agent, cost]) => (
<span key={agent} className="text-sm">
<span className="font-medium capitalize">{agent}:</span>{' '}
{formatCost(cost)}
</span>
))}
</div>
</div>
)}
</div>
{/* Verdict Reasoning */}
{review.verdict_reasoning && (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Verdict Reasoning
</h2>
<p className="text-gray-700">{review.verdict_reasoning}</p>
</div>
)}
{/* Error Message */}
{review.error_message && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold text-red-800 mb-2">Error</h2>
<p className="text-red-700">{review.error_message}</p>
</div>
)}
{/* Findings */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Findings ({review.findings.length})
</h2>
{review.findings.length === 0 ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<p className="text-green-800">No findings reported</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(findingsBySeverity).map(([severity, findings]) => (
<div key={severity}>
<h3 className="text-sm font-medium text-gray-700 mb-2 capitalize">
{severity} ({findings.length})
</h3>
<div className="space-y-2">
{findings.map((finding) => (
<FindingCard key={finding.id} finding={finding} />
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Conflicts */}
{review.conflicts.length > 0 && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Conflicts ({review.conflicts.length})
</h2>
<div className="space-y-3">
{review.conflicts.map((conflict) => (
<ConflictCard key={conflict.id} conflict={conflict} />
))}
</div>
</div>
)}
{/* Deliberation Timeline */}
{deliberation && deliberation.steps.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Deliberation Process
</h2>
<DeliberationTimeline steps={deliberation.steps} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { useReviews } from '../api/reviews';
import { ReviewList } from '../components/reviews/ReviewList';
import { ReviewFilters } from '../components/reviews/ReviewFilters';
import { Pagination } from '../components/common/Pagination';
import type { ReviewFilters as Filters } from '../types/api';
export function ReviewsPage() {
const [filters, setFilters] = useState<Filters>({
page: 1,
page_size: 20,
});
const { data, isLoading, error } = useReviews(filters);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Reviews</h1>
<p className="text-sm text-gray-600 mt-1">
View and explore code review results
</p>
</div>
<ReviewFilters filters={filters} onChange={setFilters} />
<ReviewList
reviews={data?.items || []}
isLoading={isLoading}
error={error}
/>
{data && data.pages > 1 && (
<div className="mt-4">
<Pagination
page={data.page}
pages={data.pages}
total={data.total}
pageSize={data.page_size}
onPageChange={(page) => setFilters((prev) => ({ ...prev, page }))}
/>
</div>
)}
</div>
);
}