diff --git a/web/package.json b/web/package.json index 6af98e3..5e3eca5 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,10 @@ "@tailwindcss/forms": "^0.5.7", "@upstash/ratelimit": "^1.0.0", "axios": "^1.6.8", + "chart.js": "^4.4.3", + "chartjs-adapter-date-fns": "^3.0.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", "fs": "0.0.1-security", "headlessui": "^0.0.0", "lucide-react": "^0.309.0", @@ -23,6 +26,7 @@ "nanoid": "^5.0.4", "next": "14.0.4", "react": "^18", + "react-chartjs-2": "^5.2.0", "react-dom": "^18", "react-markdown": "^9.0.1", "tailwind-merge": "^2.2.0", diff --git a/web/src/app/components/analyticstable.tsx b/web/src/app/components/analyticstable.tsx new file mode 100644 index 0000000..138e4d9 --- /dev/null +++ b/web/src/app/components/analyticstable.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import BarChart from './barchart'; +import LineChart from './linechart'; +import PieChart from './piechart'; +import { R2RClient } from '../../r2r-js-client'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../../tailwind.config'; + +const fullConfig = resolveConfig(tailwindConfig); + +const defaultColors = [ + fullConfig.theme.colors.blue[500], + fullConfig.theme.colors.red[500], + fullConfig.theme.colors.yellow[500], + fullConfig.theme.colors.teal[500], + fullConfig.theme.colors.purple[500], + fullConfig.theme.colors.orange[500], +]; + +const getColor = (index) => defaultColors[index % defaultColors.length]; + +type AnalyticsData = { + query_timestamps: string[]; + retrieval_scores: number[]; + vector_search_latencies: number[]; + rag_generation_latencies: number[]; + error_rates?: { + stackedBarChartData: { + labels: string[]; + datasets: { label: string; data: number[] }[]; + }; + }; + error_distribution?: { pieChartData: { error_type: string; count: number }[] }; +}; + +type ErrorRatesData = { + labels: string[]; + datasets: { label: string; data: number[] }[]; +}; + +type ErrorDistributionData = { + error_type: string; + count: number; +}; + +export function AnalyticsTable({ apiUrl }) { + const [selectedAnalytic, setSelectedAnalytic] = useState('Search Performance'); + const [queryMetricsData, setQueryMetricsData] = useState([]); + const [queryMetricsLabels, setQueryMetricsLabels] = useState([]); + const [retrievalScoresData, setRetrievalScoresData] = useState([]); + const [vectorSearchLatencies, setVectorSearchLatencies] = useState([]); + const [ragGenerationLatencies, setRagGenerationLatencies] = useState([]); + const [throughputData, setThroughputData] = useState([]); + const [throughputLabels, setThroughputLabels] = useState([]); + const [errorRatesData, setErrorRatesData] = useState({ labels: [], datasets: [] }); + const [errorDistributionData, setErrorDistributionData] = useState([]); + const [hasData, setHasData] = useState({ + lineChart: false, + barChart: false, + stackedBarChart: false, + pieChart: false, + retrievalScores: false, + latencyHistogram: false, + }); + const [granularity, setGranularity] = useState('minute'); + const [originalData, setOriginalData] = useState>({}); + + useEffect(() => { + const client = new R2RClient(apiUrl); + + const fetchData = async () => { + try { + const response = await client.getAnalytics(); + console.log('Full response:', response); + const data: AnalyticsData = response.results; + setOriginalData(data); + processQueryMetricsData(data.query_timestamps, granularity); + setHasData((prevState) => ({ ...prevState, lineChart: data.query_timestamps.length > 0 })); + setRetrievalScoresData(data.retrieval_scores); + setHasData((prevState) => ({ ...prevState, barChart: data.retrieval_scores.length > 0 })); + if (data.vector_search_latencies) { + setVectorSearchLatencies(data.vector_search_latencies); + setHasData((prevState) => ({ ...prevState, barChart: data.vector_search_latencies.length > 0 })); + } + if (data.rag_generation_latencies) { + setRagGenerationLatencies(data.rag_generation_latencies); + setHasData((prevState) => ({ ... prevState, barChart: data.rag_generation_latencies.length > 0})); + } + if (data.error_rates) { + setErrorRatesData(data.error_rates.stackedBarChartData); + setHasData((prevState) => ({ ...prevState, stackedBarChart: data.error_rates.stackedBarChartData.labels.length > 0 })); + } + if (data.error_distribution) { + setErrorDistributionData(data.error_distribution.pieChartData); + setHasData((prevState) => ({ ...prevState, pieChart: data.error_distribution.pieChartData.length > 0 })); + } + } catch (error) { + console.error('Error fetching analytics data:', error); + } + }; + + fetchData(); + }, [apiUrl]); + + useEffect(() => { + if (originalData.query_timestamps) { + console.log('Original Data:', originalData); + processQueryMetricsData(originalData.query_timestamps, granularity); + } + }, [granularity, originalData]); + + const processQueryMetricsData = (timestamps: string[], granularity: string) => { + if (!timestamps) return; + + const formatDate = (date: Date, granularity: string) => { + switch (granularity) { + case 'minute': + return format(date, 'yyyy-MM-dd HH:mm'); + case 'hour': + return format(date, 'yyyy-MM-dd HH:00'); + case 'day': + return format(date, 'yyyy-MM-dd'); + default: + return format(date, 'yyyy-MM-dd HH:mm:ss'); + } + }; + + const aggregatedData: { [key: string]: number } = {}; + timestamps.forEach((timestamp) => { + const date = new Date(timestamp); + const label = formatDate(date, granularity); + if (aggregatedData[label]) { + aggregatedData[label]++; + } else { + aggregatedData[label] = 1; + } + }); + + const uniqueLabels = []; + const labelCounts = {}; + Object.keys(aggregatedData).forEach(label => { + if (labelCounts[label]) { + labelCounts[label]++; + uniqueLabels.push(`${label} (${labelCounts[label]})`); + } else { + labelCounts[label] = 1; + uniqueLabels.push(label); + } + }); + + const data = uniqueLabels.map(label => aggregatedData[label]); + + setQueryMetricsLabels(uniqueLabels); + setQueryMetricsData(data); + }; + + + const renderCharts = () => { + switch (selectedAnalytic) { + case 'Query Metrics': + return ( +
+ +
+ ); + case 'Search Performance': + return ( +
+ +
+ ); + case 'Throughput and Latency': + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); + case 'Errors': + return ( +
+
+ +
+
+ ({ label: error_type, count }))} + title="Error Distribution" + className="max-w-xs" + hasData={hasData.pieChart} + noDataMessage="No data available for Error Distribution." + /> +
+
+ ); + default: + return null; + } + }; + + return ( + <> +
+

Analytics

+
+ + +
+
+
+ {renderCharts()} +
+ + ); +} diff --git a/web/src/app/components/barchart.tsx b/web/src/app/components/barchart.tsx new file mode 100644 index 0000000..58141a7 --- /dev/null +++ b/web/src/app/components/barchart.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Bar } from 'react-chartjs-2'; +import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../../tailwind.config'; + +const fullConfig = resolveConfig(tailwindConfig); + +Chart.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); + +const textColor = fullConfig.theme.colors.gray[300]; + +const defaultColors = [ + fullConfig.theme.colors.blue[500], + fullConfig.theme.colors.red[500], + fullConfig.theme.colors.yellow[500], + fullConfig.theme.colors.teal[500], + fullConfig.theme.colors.purple[500], + fullConfig.theme.colors.orange[500], +]; + +const createHistogramData = (data, label) => { + if (!Array.isArray(data)) { + console.error('Data passed to createHistogramData is not an array:', data); + return { + labels: [], + datasets: [] + }; + } + + const max = Math.max(...data); + const binCount = max > 1 ? 10 : 10; // Always 10 bins + const binSize = max > 1 ? max / binCount : 0.1; + + const bins = Array.from({ length: binCount }, (_, i) => i * binSize); + const histogram = bins.map((bin, index) => { + const nextBin = bins[index + 1] ?? max + binSize; + if (index === bins.length - 1) { + return data.filter(value => value >= bin && value <= max).length; + } + return data.filter(value => value >= bin && value < nextBin).length; + }); + + return { + labels: bins.map((bin, index) => { + const nextBin = bins[index + 1] ?? max; + return `${bin.toFixed(1)} - ${nextBin.toFixed(1)}`; + }), + datasets: [ + { + label, + backgroundColor: defaultColors[0], + borderColor: defaultColors[0], + borderWidth: 1, + data: histogram, + barPercentage: 1.0, + categoryPercentage: 1.0, + }, + ], + }; +}; + +const BarChart = ({ + data, + title = 'Default Bar Chart', + xTitle = 'X Axis', + yTitle = 'Y Axis', + label = 'Default Bar Chart', + barPercentage = 0.9, + categoryPercentage = 0.8, + isHistogram = false, + isStacked = false, + hasData = true, + noDataMessage = 'No data available', +}) => { + // Validate data structure + const validData = data && Array.isArray(data.datasets) && data.datasets.length > 0 && Array.isArray(data.datasets[0].data); + + const barChartData = isHistogram + ? createHistogramData(data.datasets[0].data, 0.1, label) + : { + ...data, + datasets: data.datasets ? data.datasets.map((dataset, index) => ({ + ...dataset, + backgroundColor: defaultColors[index % defaultColors.length], + borderColor: defaultColors[index % defaultColors.length], + })) : [], + }; + + const options = { + responsive: true, + plugins: { + legend: { + position: 'top', + labels: { + color: textColor, + }, + }, + title: { + display: true, + text: title, + color: textColor, + }, + tooltip: { + callbacks: { + label: (context) => { + const label = context.dataset.label || ''; + const value = context.raw; + const range = context.label; + return `${label}: ${value} (Range: ${range})`; + }, + }, + }, + }, + scales: { + x: { + title: { + display: true, + text: xTitle, + color: textColor, + }, + ticks: { + color: textColor, + maxRotation: 45, + minRotation: 45, + }, + grid: { + offset: isHistogram ? false : true, + }, + stacked: isStacked, + }, + y: { + title: { + display: true, + text: yTitle, + color: textColor, + }, + ticks: { + color: textColor, + }, + beginAtZero: true, + stacked: isStacked, + }, + }, + }; + + return ( +
+ + {!hasData && ( +
+ {noDataMessage} +
+ )} +
+ ); +}; + +BarChart.createHistogramData = createHistogramData; + +export default BarChart; diff --git a/web/src/app/components/linechart.tsx b/web/src/app/components/linechart.tsx new file mode 100644 index 0000000..aaaf241 --- /dev/null +++ b/web/src/app/components/linechart.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import { Chart, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale } from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../../tailwind.config'; + +const fullConfig = resolveConfig(tailwindConfig); + +Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, TimeScale); + +const textColor = fullConfig.theme.colors.gray[300]; + +const defaultColors = [ + fullConfig.theme.colors.blue[500], + fullConfig.theme.colors.red[500], + fullConfig.theme.colors.yellow[500], + fullConfig.theme.colors.teal[500], + fullConfig.theme.colors.purple[500], + fullConfig.theme.colors.orange[500], +]; + +const LineChart = ({ + data, + labels, + title = 'Default Line Chart', + xTitle = 'X Axis', + yTitle = 'Y Axis', + hasData = true, + noDataMessage = 'No data available', + timeScale = false, + granularity = 'minute', +}: { + data: number[]; + labels: string[]; + title?: string; + xTitle?: string; + yTitle?: string; + hasData?: boolean; + noDataMessage?: string; + timeScale?: boolean; + granularity?: string; +}) => { + + const isEmpty = data.length === 0 || labels.length === 0; + + const lineChartData = { + labels: isEmpty ? [] : labels.slice(-10), + datasets: [ + { + label: title, + data: isEmpty ? [] : data.slice(-10), + backgroundColor: defaultColors[0], + borderColor: defaultColors[0], + fill: false, + }, + ], + }; + + const options = { + responsive: true, + plugins: { + legend: { + position: 'top', + labels: { + color: textColor, + }, + }, + title: { + display: true, + text: title, + color: textColor, + }, + tooltip: { + callbacks: { + label: (context) => { + const value = context.raw; + return `${context.dataset.label}: ${value}`; + }, + }, + }, + }, + scales: { + x: { + type: timeScale ? 'time' : 'category', + time: { + unit: granularity, + tooltipFormat: 'yyyy-MM-dd HH:mm', + displayFormats: { + minute: 'yyyy-MM-dd HH:mm', + hour: 'yyyy-MM-dd HH:00', + day: 'yyyy-MM-dd', + }, + }, + title: { + display: true, + text: xTitle, + color: textColor, + }, + ticks: { + color: textColor, + autoSkip: true, + maxTicksLimit: 10, + }, + grid: { + color: fullConfig.theme.colors.gray[800], + }, + adapters: { + date: { + locale: enUS, + }, + }, + }, + y: { + title: { + display: true, + text: yTitle, + color: textColor, + }, + ticks: { + color: textColor, + }, + grid: { + color: fullConfig.theme.colors.gray[800], + }, + beginAtZero: true, + }, + }, + }; + + return ( +
+ {!isEmpty ? ( + + ) : ( +
+ {noDataMessage} +
+ )} +
+ ); +}; + +export default LineChart; \ No newline at end of file diff --git a/web/src/app/components/piechart.tsx b/web/src/app/components/piechart.tsx new file mode 100644 index 0000000..01c3738 --- /dev/null +++ b/web/src/app/components/piechart.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Pie } from 'react-chartjs-2'; +import { Chart, ArcElement, Tooltip, Legend } from 'chart.js'; +import resolveConfig from 'tailwindcss/resolveConfig'; +import tailwindConfig from '../../../tailwind.config'; + +const fullConfig = resolveConfig(tailwindConfig); + +Chart.register(ArcElement, Tooltip, Legend); + +const textColor = fullConfig.theme.colors.gray[300]; + +const defaultColors = [ + fullConfig.theme.colors.blue[500], + fullConfig.theme.colors.red[500], + fullConfig.theme.colors.yellow[500], + fullConfig.theme.colors.teal[500], + fullConfig.theme.colors.purple[500], + fullConfig.theme.colors.orange[500], +]; + +const PieChart = ({ + data, + title = 'Default Pie Chart', + hasData = true, + noDataMessage = 'No data available', + className = '', +}: { + data: { label: string; count: number }[]; + title?: string; + hasData?: boolean; + noDataMessage?: string; + className?: string; +}) => { + const pieChartData = { + labels: data.map((entry) => entry.label), + datasets: [ + { + data: data.map((entry) => entry.count), + backgroundColor: data.map((_, index) => defaultColors[index % defaultColors.length]), + borderColor: data.map((_, index) => defaultColors[index % defaultColors.length]), + borderWidth: 1, + }, + ], + }; + + const options = { + responsive: true, + plugins: { + legend: { + position: 'top', + labels: { + color: textColor, + }, + }, + title: { + display: true, + text: title, + color: textColor, + }, + }, + }; + + return ( +
+ + {!hasData && ( +
+ {noDataMessage} +
+ )} +
+ ); +}; + +export default PieChart; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index c5a86df..d5839a8 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -8,6 +8,7 @@ import { Title } from "@/app/components/title"; import { Result } from "@/app/components/result"; import { Search } from "@/app/components/search"; import { LogTable } from "@/app/components/logtable"; +import { AnalyticsTable } from "@/app/components/analyticstable"; const Index: React.FC = () => { const [tooltipVisible, setTooltipVisible] = useState(false); @@ -140,7 +141,7 @@ const Index: React.FC = () => { ) : ( -
+
+ +
+
+ +
+
); }; diff --git a/web/src/r2r-js-client/r2rClient.ts b/web/src/r2r-js-client/r2rClient.ts index 10000b5..f5c5022 100644 --- a/web/src/r2r-js-client/r2rClient.ts +++ b/web/src/r2r-js-client/r2rClient.ts @@ -253,6 +253,23 @@ export class R2RClient { return parseLogs(logs); } + + async getAnalytics(): Promise { + const url = `${this.baseUrl}/analytics/`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return response.json(); + } + generateRunId() { return uuidv4(); } diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 28004e9..250f9b1 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -17,9 +17,25 @@ const config: Config = { blue: { 500: "#2F80ED", }, + red: { + 500: "#FF6384", + }, + yellow: { + 500: "#FFCD56", + }, + teal: { + 500: "#36A2EB", + }, + purple: { + 500: "#9C27B0", + }, + orange: { + 500: "#FF9F40", + }, }, }, }, plugins: [require("@tailwindcss/typography")], }; + export default config;