diff --git a/dashboard/src/components/reviews/FindingCard.tsx b/dashboard/src/components/reviews/FindingCard.tsx new file mode 100644 index 0000000..9491446 --- /dev/null +++ b/dashboard/src/components/reviews/FindingCard.tsx @@ -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 = { + 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 ( +
+ + {isExpanded && ( +
+
+

+ Description +

+

{finding.description}

+
+
+

+ Reasoning +

+

{finding.reasoning}

+
+ {finding.suggestion && ( +
+

+ Suggestion +

+

{finding.suggestion}

+
+ )} + {finding.references.length > 0 && ( +
+

+ References +

+
    + {finding.references.map((ref, index) => ( +
  • + + {ref} + +
  • + ))} +
+
+ )} +
+ Prompt version: {finding.prompt_version} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/components/reviews/ReviewCard.tsx b/dashboard/src/components/reviews/ReviewCard.tsx new file mode 100644 index 0000000..98710e4 --- /dev/null +++ b/dashboard/src/components/reviews/ReviewCard.tsx @@ -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 ( + +
+
+
+ + {review.repository} + + #{review.pr_number} +
+ {review.pr_title && ( +

{review.pr_title}

+ )} +
+ {review.author && by {review.author}} + {formatDate(review.created_at)} +
+
+
+ +
+ + {review.finding_count} findings + + {review.critical_count > 0 && ( + + {review.critical_count} critical + + )} + {review.high_count > 0 && ( + + {review.high_count} high + + )} +
+ + {formatCost(review.total_cost_usd)} + +
+
+ + ); +} diff --git a/dashboard/src/components/reviews/ReviewFilters.tsx b/dashboard/src/components/reviews/ReviewFilters.tsx new file mode 100644 index 0000000..8fd0271 --- /dev/null +++ b/dashboard/src/components/reviews/ReviewFilters.tsx @@ -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 ( +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/components/reviews/ReviewList.tsx b/dashboard/src/components/reviews/ReviewList.tsx new file mode 100644 index 0000000..c5db378 --- /dev/null +++ b/dashboard/src/components/reviews/ReviewList.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load reviews

+

{error.message}

+
+ ); + } + + if (reviews.length === 0) { + return ( +
+

No reviews found

+

+ Try adjusting your filters or check back later +

+
+ ); + } + + return ( +
+ {reviews.map((review) => ( + + ))} +
+ ); +} diff --git a/dashboard/src/pages/ReviewDetailPage.tsx b/dashboard/src/pages/ReviewDetailPage.tsx new file mode 100644 index 0000000..bef8adc --- /dev/null +++ b/dashboard/src/pages/ReviewDetailPage.tsx @@ -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 ( +
+ +
+ ); + } + + if (error || !review) { + return ( +
+

Failed to load review

+

{error?.message}

+ + Back to reviews + +
+ ); + } + + 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, + ); + + return ( +
+
+ + ← Back to reviews + +
+
+

+ {review.repository} #{review.pr_number} +

+ {review.pr_title && ( +

{review.pr_title}

+ )} +
+ +
+
+ + {/* Meta Info */} +
+
+
+ Author +

{review.author || '-'}

+
+
+ Status +

{review.status}

+
+
+ Created +

{formatDate(review.created_at)}

+
+
+ Completed +

{formatDate(review.completed_at)}

+
+
+
+
+ Total Cost +

{formatCost(review.total_cost_usd)}

+
+
+ Total Tokens +

+ {review.total_tokens?.toLocaleString() || '-'} +

+
+
+ Base SHA +

{review.base_sha.slice(0, 7)}

+
+
+ Head SHA +

{review.head_sha.slice(0, 7)}

+
+
+ {review.cost_by_agent && Object.keys(review.cost_by_agent).length > 0 && ( +
+ Cost by Agent +
+ {Object.entries(review.cost_by_agent).map(([agent, cost]) => ( + + {agent}:{' '} + {formatCost(cost)} + + ))} +
+
+ )} +
+ + {/* Verdict Reasoning */} + {review.verdict_reasoning && ( +
+

+ Verdict Reasoning +

+

{review.verdict_reasoning}

+
+ )} + + {/* Error Message */} + {review.error_message && ( +
+

Error

+

{review.error_message}

+
+ )} + + {/* Findings */} +
+

+ Findings ({review.findings.length}) +

+ {review.findings.length === 0 ? ( +
+

No findings reported

+
+ ) : ( +
+ {Object.entries(findingsBySeverity).map(([severity, findings]) => ( +
+

+ {severity} ({findings.length}) +

+
+ {findings.map((finding) => ( + + ))} +
+
+ ))} +
+ )} +
+ + {/* Conflicts */} + {review.conflicts.length > 0 && ( +
+

+ Conflicts ({review.conflicts.length}) +

+
+ {review.conflicts.map((conflict) => ( + + ))} +
+
+ )} + + {/* Deliberation Timeline */} + {deliberation && deliberation.steps.length > 0 && ( +
+

+ Deliberation Process +

+ +
+ )} +
+ ); +} diff --git a/dashboard/src/pages/ReviewsPage.tsx b/dashboard/src/pages/ReviewsPage.tsx new file mode 100644 index 0000000..9ec6231 --- /dev/null +++ b/dashboard/src/pages/ReviewsPage.tsx @@ -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({ + page: 1, + page_size: 20, + }); + + const { data, isLoading, error } = useReviews(filters); + + return ( +
+
+

Reviews

+

+ View and explore code review results +

+
+ + + + + + {data && data.pages > 1 && ( +
+ setFilters((prev) => ({ ...prev, page }))} + /> +
+ )} +
+ ); +}