Initial commit - WhyRating Engine (Google Reviews Scraper)
This commit is contained in:
509
web/components/ReportsView.tsx
Normal file
509
web/components/ReportsView.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ExecutionStatus } from '@/lib/pipeline-types';
|
||||
import { JobStatus } from './ScraperTest';
|
||||
|
||||
interface ReportsViewProps {
|
||||
executions: ExecutionStatus[];
|
||||
jobsMap: Map<string, JobStatus>;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
// Helper to format duration
|
||||
function formatDuration(ms: number | null | undefined): string {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
// Sort icon component
|
||||
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
|
||||
if (!sorted) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={`w-4 h-4 ${sorted === 'asc' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract business name from execution using jobs map
|
||||
function extractBusinessName(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): string {
|
||||
// First try to get from the linked job
|
||||
if (execution.job_id) {
|
||||
const job = jobsMap.get(execution.job_id);
|
||||
if (job?.business_name) return job.business_name;
|
||||
}
|
||||
// Try to get from result_summary
|
||||
if (execution.result_summary) {
|
||||
const summary = execution.result_summary as Record<string, unknown>;
|
||||
if (summary.business_name) return String(summary.business_name);
|
||||
}
|
||||
// Try to get from input_summary
|
||||
if (execution.input_summary) {
|
||||
const summary = execution.input_summary as Record<string, unknown>;
|
||||
if (summary.business_name) return String(summary.business_name);
|
||||
}
|
||||
// Fallback to business_id
|
||||
if (execution.business_id) return execution.business_id;
|
||||
return 'Unknown Business';
|
||||
}
|
||||
|
||||
// Extract review count from execution using jobs map
|
||||
function extractReviewCount(execution: ExecutionStatus, jobsMap: Map<string, JobStatus>): number | null {
|
||||
// First try to get from the linked job
|
||||
if (execution.job_id) {
|
||||
const job = jobsMap.get(execution.job_id);
|
||||
if (job?.reviews_count) return job.reviews_count;
|
||||
}
|
||||
if (execution.result_summary) {
|
||||
const summary = execution.result_summary as Record<string, unknown>;
|
||||
if (typeof summary.review_count === 'number') return summary.review_count;
|
||||
if (typeof summary.reviews_processed === 'number') return summary.reviews_processed;
|
||||
}
|
||||
if (execution.input_summary) {
|
||||
const summary = execution.input_summary as Record<string, unknown>;
|
||||
if (typeof summary.review_count === 'number') return summary.review_count;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ReportsView({ executions, jobsMap, onRefresh }: ReportsViewProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'created_at', desc: true }]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
// Filter executions by status
|
||||
const filteredExecutions = useMemo(() => {
|
||||
if (statusFilter === 'all') return executions;
|
||||
return executions.filter(e => e.status === statusFilter);
|
||||
}, [executions, statusFilter]);
|
||||
|
||||
// Status counts for filter badges
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: executions.length };
|
||||
executions.forEach(e => {
|
||||
counts[e.status] = (counts[e.status] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [executions]);
|
||||
|
||||
// Calculate summary stats
|
||||
const stats = useMemo(() => {
|
||||
const completed = executions.filter(e => e.status === 'completed');
|
||||
const failed = executions.filter(e => e.status === 'failed');
|
||||
const running = executions.filter(e => e.status === 'running');
|
||||
|
||||
const totalReviews = completed.reduce((sum, e) => {
|
||||
const count = extractReviewCount(e, jobsMap);
|
||||
return sum + (count || 0);
|
||||
}, 0);
|
||||
|
||||
const avgDuration = completed.length > 0
|
||||
? completed.reduce((sum, e) => sum + (e.total_duration_ms || 0), 0) / completed.length
|
||||
: 0;
|
||||
|
||||
const successRate = executions.length > 0
|
||||
? (completed.length / executions.length) * 100
|
||||
: 0;
|
||||
|
||||
// Reports today
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const reportsToday = executions.filter(e => new Date(e.created_at || '') >= today).length;
|
||||
|
||||
return {
|
||||
total: executions.length,
|
||||
completed: completed.length,
|
||||
failed: failed.length,
|
||||
running: running.length,
|
||||
totalReviews,
|
||||
avgDuration,
|
||||
successRate,
|
||||
reportsToday,
|
||||
};
|
||||
}, [executions, jobsMap]);
|
||||
|
||||
const columns = useMemo<ColumnDef<ExecutionStatus>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'business',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Business
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
accessorFn: (row) => extractBusinessName(row, jobsMap),
|
||||
cell: ({ row }) => {
|
||||
const name = extractBusinessName(row.original, jobsMap);
|
||||
const reviewCount = extractReviewCount(row.original, jobsMap);
|
||||
return (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold text-gray-900 truncate" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
{reviewCount !== null && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{reviewCount.toLocaleString()} reviews analyzed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'job_id',
|
||||
header: 'Job',
|
||||
cell: ({ row }) => {
|
||||
const jobId = row.original.job_id;
|
||||
if (!jobId) return <span className="text-gray-400">-</span>;
|
||||
return (
|
||||
<Link
|
||||
href={`/jobs/${jobId}`}
|
||||
className="text-blue-600 hover:text-blue-800 text-xs font-mono bg-blue-50 px-2 py-1 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{jobId.slice(0, 8)}...
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Status
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
status === 'running' ? 'bg-blue-100 text-blue-800' :
|
||||
status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
status === 'cancelled' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{status === 'running' && (
|
||||
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'stages_completed',
|
||||
header: 'Stages',
|
||||
cell: ({ row }) => {
|
||||
const requested = row.original.stages_requested || [];
|
||||
const completed = row.original.stages_completed || [];
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{requested.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
className={`px-1.5 py-0.5 text-xs rounded ${
|
||||
completed.includes(stage)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: row.original.current_stage === stage
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
title={stage}
|
||||
>
|
||||
{stage.slice(0, 3)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'total_duration_ms',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Duration
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{formatDuration(row.original.total_duration_ms)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<button
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||
>
|
||||
Created
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const dateStr = row.original.created_at;
|
||||
if (!dateStr) return <span className="text-gray-400">-</span>;
|
||||
const date = new Date(dateStr);
|
||||
const isToday = new Date().toDateString() === date.toDateString();
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{isToday ? 'Today' : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = rowA.original.created_at ? new Date(rowA.original.created_at).getTime() : 0;
|
||||
const dateB = rowB.original.created_at ? new Date(rowB.original.created_at).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const execution = row.original;
|
||||
const isCompleted = execution.status === 'completed';
|
||||
const hasJobId = !!execution.job_id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Report - only for completed with job_id */}
|
||||
{isCompleted && hasJobId && (
|
||||
<Link
|
||||
href={`/pipelines/reviewiq/analytics?job_id=${execution.job_id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white text-xs font-semibold rounded-lg transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Report"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
View Report
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Metrics button */}
|
||||
<Link
|
||||
href={`/pipelines/reviewiq/executions/${execution.id}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-medium rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Execution Details"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[jobsMap]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredExecutions,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Total Reports</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Success Rate</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.successRate.toFixed(0)}%</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Reviews Analyzed</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalReviews.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500 mb-1">Today</div>
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.reportsToday}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'completed', 'running', 'failed', 'pending'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||
statusFilter === status
|
||||
? status === 'completed' ? 'bg-green-600 text-white' :
|
||||
status === 'running' ? 'bg-blue-600 text-white' :
|
||||
status === 'failed' ? 'bg-red-600 text-white' :
|
||||
'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
{statusCounts[status] !== undefined && (
|
||||
<span className="ml-1.5 text-xs opacity-80">({statusCounts[status]})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-gray-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg className="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>No reports found</span>
|
||||
<span className="text-sm text-gray-400">Run a pipeline to generate reports</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-4 py-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {table.getRowModel().rows.length} of {filteredExecutions.length} reports
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user