diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index a1964a4b..3b9f1e08 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -323,3 +323,164 @@ class MonitoringRouterGroup(group.RouterGroup): return self.error(message=f'Message {message_id} not found', code=404) return self.success(data=details) + + @self.route('/export', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def export_data() -> tuple[str, int]: + """Export monitoring data as CSV""" + # Parse query parameters + export_type = quart.request.args.get('type', 'messages') + bot_ids = quart.request.args.getlist('botId') + pipeline_ids = quart.request.args.getlist('pipelineId') + start_time_str = quart.request.args.get('startTime') + end_time_str = quart.request.args.get('endTime') + limit = int(quart.request.args.get('limit', 100000)) + + # Parse datetime + start_time = parse_iso_datetime(start_time_str) + end_time = parse_iso_datetime(end_time_str) + + # Get data based on export type + if export_type == 'messages': + data = await self.ap.monitoring_service.export_messages( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'runner_name', + 'message_content', + 'message_text', + 'session_id', + 'status', + 'level', + 'platform', + 'user_id', + ] + elif export_type == 'llm-calls': + data = await self.ap.monitoring_service.export_llm_calls( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'model_name', + 'input_tokens', + 'output_tokens', + 'total_tokens', + 'duration_ms', + 'cost', + 'status', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + 'message_id', + 'error_message', + ] + elif export_type == 'embedding-calls': + data = await self.ap.monitoring_service.export_embedding_calls( + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'model_name', + 'prompt_tokens', + 'total_tokens', + 'duration_ms', + 'input_count', + 'status', + 'error_message', + 'knowledge_base_id', + 'query_text', + 'session_id', + 'message_id', + 'call_type', + ] + elif export_type == 'errors': + data = await self.ap.monitoring_service.export_errors( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'id', + 'timestamp', + 'error_type', + 'error_message', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'session_id', + 'message_id', + 'stack_trace', + ] + elif export_type == 'sessions': + data = await self.ap.monitoring_service.export_sessions( + bot_ids=bot_ids if bot_ids else None, + pipeline_ids=pipeline_ids if pipeline_ids else None, + start_time=start_time, + end_time=end_time, + limit=limit, + ) + headers = [ + 'session_id', + 'bot_id', + 'bot_name', + 'pipeline_id', + 'pipeline_name', + 'message_count', + 'start_time', + 'last_activity', + 'is_active', + 'platform', + 'user_id', + ] + else: + return self.error(message=f'Invalid export type: {export_type}', code=400) + + # Generate CSV content with UTF-8 BOM for Excel compatibility + import io + + output = io.StringIO() + # Write UTF-8 BOM for Excel + output.write('\ufeff') + # Write header + output.write(','.join(headers) + '\n') + + # Escape and write each row + for row in data: + escaped_values = [] + for header in headers: + value = row.get(header, '') + escaped_values.append(self.ap.monitoring_service._escape_csv_field(value)) + output.write(','.join(escaped_values) + '\n') + + csv_content = output.getvalue() + + # Return as file download + response = await quart.make_response(csv_content) + response.headers['Content-Type'] = 'text/csv; charset=utf-8' + response.headers['Content-Disposition'] = ( + f'attachment; filename="monitoring-{export_type}-{int(datetime.datetime.now().timestamp())}.csv"' + ) + + return response, 200 diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index abd3e510..b9983519 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -794,3 +794,332 @@ class MonitoringService: }, 'errors': errors, } + + # ========== Export Methods ========== + + def _escape_csv_field(self, field: str | None) -> str: + """Escape a field for CSV output""" + if field is None: + return '' + # Convert non-string types to string first + if not isinstance(field, str): + field = str(field) + # Replace common escape sequences + field = field.replace('\r\n', '\n').replace('\r', '\n') + # If field contains comma, double quote, or newline, wrap in quotes + if ',' in field or '"' in field or '\n' in field: + # Escape double quotes by doubling them + field = '"' + field.replace('"', '""') + '"' + return field + + def _format_timestamp(self, dt: datetime.datetime) -> str: + """Format datetime to ISO format string""" + return dt.strftime('%Y-%m-%d %H:%M:%S') + + def _extract_message_text(self, message_content: str) -> str: + """Extract plain text from message chain JSON""" + if not message_content: + return '' + + try: + import json + + message_chain = json.loads(message_content) + if not isinstance(message_chain, list): + return message_content + + text_parts = [] + for component in message_chain: + if not isinstance(component, dict): + continue + component_type = component.get('type') + if component_type == 'Plain': + text = component.get('text', '') + text_parts.append(text) + elif component_type == 'At': + display = component.get('display', '') + target = component.get('target', '') + if display: + text_parts.append(f'@{display}') + elif target: + text_parts.append(f'@{target}') + elif component_type == 'AtAll': + text_parts.append('@All') + elif component_type == 'Image': + text_parts.append('[Image]') + elif component_type == 'File': + name = component.get('name', 'File') + text_parts.append(f'[File: {name}]') + elif component_type == 'Voice': + length = component.get('length', 0) + text_parts.append(f'[Voice {length}s]') + elif component_type == 'Quote': + # Quote content is in 'origin' field + origin = component.get('origin', []) + if isinstance(origin, list): + for item in origin: + if isinstance(item, dict) and item.get('type') == 'Plain': + text_parts.append(f'> {item.get("text", "")}') + elif component_type == 'Source': + # Skip Source component + continue + else: + # Other unknown types + text_parts.append(f'[{component_type}]') + + return ''.join(text_parts) + except (json.JSONDecodeError, TypeError, KeyError): + # If not valid JSON, return as-is + return message_content + + async def export_messages( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export messages as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by( + persistence_monitoring.MonitoringMessage.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'runner_name': row[0].runner_name if isinstance(row, tuple) else row.runner_name, + 'message_content': row[0].message_content if isinstance(row, tuple) else row.message_content, + 'message_text': self._extract_message_text( + row[0].message_content if isinstance(row, tuple) else row.message_content + ), + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'level': row[0].level if isinstance(row, tuple) else row.level, + 'platform': row[0].platform if isinstance(row, tuple) else row.platform, + 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, + } + for row in rows + ] + + async def export_llm_calls( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export LLM calls as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by( + persistence_monitoring.MonitoringLLMCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, + 'input_tokens': row[0].input_tokens if isinstance(row, tuple) else row.input_tokens, + 'output_tokens': row[0].output_tokens if isinstance(row, tuple) else row.output_tokens, + 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, + 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, + 'cost': row[0].cost if isinstance(row, tuple) else row.cost, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + } + for row in rows + ] + + async def export_embedding_calls( + self, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + knowledge_base_id: str | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export embedding calls as list of dictionaries for CSV conversion""" + conditions = [] + + if start_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time) + if knowledge_base_id: + conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id) + + query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by( + persistence_monitoring.MonitoringEmbeddingCall.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'model_name': row[0].model_name if isinstance(row, tuple) else row.model_name, + 'prompt_tokens': row[0].prompt_tokens if isinstance(row, tuple) else row.prompt_tokens, + 'total_tokens': row[0].total_tokens if isinstance(row, tuple) else row.total_tokens, + 'duration_ms': row[0].duration if isinstance(row, tuple) else row.duration, + 'input_count': row[0].input_count if isinstance(row, tuple) else row.input_count, + 'status': row[0].status if isinstance(row, tuple) else row.status, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + 'knowledge_base_id': row[0].knowledge_base_id if isinstance(row, tuple) else row.knowledge_base_id, + 'query_text': row[0].query_text if isinstance(row, tuple) else row.query_text, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'call_type': row[0].call_type if isinstance(row, tuple) else row.call_type, + } + for row in rows + ] + + async def export_errors( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export errors as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by( + persistence_monitoring.MonitoringError.timestamp.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'id': row[0].id if isinstance(row, tuple) else row.id, + 'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp), + 'error_type': row[0].error_type if isinstance(row, tuple) else row.error_type, + 'error_message': row[0].error_message if isinstance(row, tuple) else row.error_message, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id, + 'stack_trace': row[0].stack_trace if isinstance(row, tuple) else row.stack_trace, + } + for row in rows + ] + + async def export_sessions( + self, + bot_ids: list[str] | None = None, + pipeline_ids: list[str] | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, + limit: int = 100000, + ) -> list[dict]: + """Export sessions as list of dictionaries for CSV conversion""" + conditions = [] + + if bot_ids: + conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids)) + if pipeline_ids: + conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids)) + if start_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) + if end_time: + conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + + query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by( + persistence_monitoring.MonitoringSession.last_activity.desc() + ) + if conditions: + query = query.where(sqlalchemy.and_(*conditions)) + + query = query.limit(limit) + + result = await self.ap.persistence_mgr.execute_async(query) + rows = result.all() + + return [ + { + 'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id, + 'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id, + 'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name, + 'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id, + 'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name, + 'message_count': row[0].message_count if isinstance(row, tuple) else row.message_count, + 'start_time': self._format_timestamp(row[0].start_time if isinstance(row, tuple) else row.start_time), + 'last_activity': self._format_timestamp( + row[0].last_activity if isinstance(row, tuple) else row.last_activity + ), + 'is_active': str(row[0].is_active if isinstance(row, tuple) else row.is_active), + 'platform': row[0].platform if isinstance(row, tuple) else row.platform, + 'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id, + } + for row in rows + ] diff --git a/web/.prettierrc.mjs b/web/.prettierrc.mjs index 01ed3d48..968c0f78 100644 --- a/web/.prettierrc.mjs +++ b/web/.prettierrc.mjs @@ -15,7 +15,6 @@ const config = { singleQuote: true, // 大括号前后空格 bracketSpacing: true, - attributeVerticalAlignment: 'auto', trailingComma: 'all', }; diff --git a/web/src/app/home/monitoring/components/ExportDropdown.tsx b/web/src/app/home/monitoring/components/ExportDropdown.tsx new file mode 100644 index 00000000..b0a537e0 --- /dev/null +++ b/web/src/app/home/monitoring/components/ExportDropdown.tsx @@ -0,0 +1,227 @@ +'use client'; + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Download, + FileText, + Database, + AlertCircle, + Users, + Layers, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { backendClient } from '@/app/infra/http'; +import { FilterState } from '../types/monitoring'; + +export type ExportType = + | 'messages' + | 'llm-calls' + | 'embedding-calls' + | 'errors' + | 'sessions'; + +interface ExportDropdownProps { + filterState: FilterState; +} + +export function ExportDropdown({ filterState }: ExportDropdownProps) { + const { t } = useTranslation(); + const [exporting, setExporting] = useState(null); + + const getDateRangeParams = (): { startTime: string; endTime: string } => { + const now = new Date(); + let startTime: Date; + let endTime: Date = now; + + switch (filterState.timeRange) { + case 'lastHour': + startTime = new Date(now.getTime() - 60 * 60 * 1000); + break; + case 'last6Hours': + startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000); + break; + case 'last24Hours': + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); + break; + case 'last7Days': + startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case 'last30Days': + startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + case 'custom': + if (filterState.customDateRange) { + startTime = filterState.customDateRange.from; + endTime = filterState.customDateRange.to; + } else { + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } + break; + default: + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } + + return { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + }; + + const handleExport = async (type: ExportType) => { + setExporting(type); + try { + const { startTime, endTime } = getDateRangeParams(); + const params = new URLSearchParams({ + type, + startTime, + endTime, + }); + + if (filterState.selectedBots.length > 0) { + filterState.selectedBots.forEach((botId) => { + params.append('botId', botId); + }); + } + + if (filterState.selectedPipelines.length > 0) { + filterState.selectedPipelines.forEach((pipelineId) => { + params.append('pipelineId', pipelineId); + }); + } + + // Use backendClient's downloadFile method for blob response + const response = await backendClient.downloadFile( + `/api/v1/monitoring/export?${params.toString()}`, + ); + + // Get filename from content-disposition header + const contentDisposition = response.headers['content-disposition']; + let filename = `monitoring-${type}-${Date.now()}.csv`; + if (contentDisposition) { + const filenameMatch = contentDisposition.match( + /filename="?([^";\n]+)"?/, + ); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + // Create download link + const blob = new Blob([response.data], { + type: 'text/csv;charset=utf-8;', + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('Failed to export data:', error); + } finally { + setExporting(null); + } + }; + + const exportOptions: { + type: ExportType; + label: string; + icon: React.ReactNode; + }[] = [ + { + type: 'messages', + label: t('monitoring.export.messages'), + icon: , + }, + { + type: 'llm-calls', + label: t('monitoring.export.llmCalls'), + icon: , + }, + { + type: 'embedding-calls', + label: t('monitoring.export.embeddingCalls'), + icon: , + }, + { + type: 'errors', + label: t('monitoring.export.errors'), + icon: , + }, + { + type: 'sessions', + label: t('monitoring.export.sessions'), + icon: , + }, + ]; + + return ( + + + + + + {t('monitoring.export.title')} + + {exportOptions.map((option) => ( + handleExport(option.type)} + disabled={exporting !== null} + className="cursor-pointer" + > + {option.icon} + {option.label} + + ))} + + + ); +} diff --git a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx index f4ac6bbd..97d68e4d 100644 --- a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx +++ b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx @@ -74,7 +74,10 @@ export default function TrafficChart({ // <= 7 days: 4-hour buckets bucketSize = 4 * 60 * 60 * 1000; formatTime = (date) => - `${date.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`; + `${date.toLocaleDateString([], { + month: 'short', + day: 'numeric', + })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`; } else { // > 7 days: 1-day buckets bucketSize = 24 * 60 * 60 * 1000; diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx index d7ebc486..ae503055 100644 --- a/web/src/app/home/monitoring/page.tsx +++ b/web/src/app/home/monitoring/page.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react'; import OverviewCards from './components/overview-cards/OverviewCards'; import MonitoringFilters from './components/filters/MonitoringFilters'; +import { ExportDropdown } from './components/ExportDropdown'; import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringData } from './hooks/useMonitoringData'; import { MessageDetailsCard } from './components/MessageDetailsCard'; @@ -200,22 +201,25 @@ function MonitoringPageContent() { onPipelinesChange={setSelectedPipelines} onTimeRangeChange={setTimeRange} /> - + + + + {t('monitoring.refreshData')} + + diff --git a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx index d8d1b3db..baedfee3 100644 --- a/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx +++ b/web/src/app/home/plugins/components/plugin-installed/PluginInstalledComponent.tsx @@ -389,7 +389,9 @@ const PluginInstalledComponent = forwardRef( {readmePlugin && - `${readmePlugin.author}/${readmePlugin.name} - ${t('plugins.readme')}`} + `${readmePlugin.author}/${readmePlugin.name} - ${t( + 'plugins.readme', + )}`}
diff --git a/web/src/app/infra/http/BaseHttpClient.ts b/web/src/app/infra/http/BaseHttpClient.ts index c90e95d6..e4191330 100644 --- a/web/src/app/infra/http/BaseHttpClient.ts +++ b/web/src/app/infra/http/BaseHttpClient.ts @@ -206,4 +206,19 @@ export abstract class BaseHttpClient { ...config, }); } + + public async downloadFile( + url: string, + config?: RequestConfig, + ): Promise> { + try { + const response = await this.instance.get(url, { + responseType: 'blob', + ...config, + }); + return response; + } catch (error) { + return this.handleError(error as object); + } + } } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index e988ad31..685291ca 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -952,6 +952,15 @@ const enUS = { viewMonitoring: 'View Monitoring', refreshData: 'Refresh Data', exportData: 'Export Data', + export: { + title: 'Export Data', + exporting: 'Exporting...', + messages: 'Messages', + llmCalls: 'LLM Calls', + embeddingCalls: 'Embedding Calls', + errors: 'Error Logs', + sessions: 'Sessions', + }, }, }; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 64b63cca..bf23a172 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -939,6 +939,15 @@ const jaJP = { viewMonitoring: 'モニタリングを表示', refreshData: 'データを更新', exportData: 'データをエクスポート', + export: { + title: 'データをエクスポート', + exporting: 'エクスポート中...', + messages: 'メッセージ', + llmCalls: 'LLM コール', + embeddingCalls: 'Embedding コール', + errors: 'エラーログ', + sessions: 'セッション', + }, }, }; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 141f073d..f41b2a67 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -912,6 +912,15 @@ const zhHans = { viewMonitoring: '查看日志监控', refreshData: '刷新数据', exportData: '导出数据', + export: { + title: '导出数据', + exporting: '导出中...', + messages: '消息记录', + llmCalls: 'LLM 调用', + embeddingCalls: 'Embedding 调用', + errors: '错误日志', + sessions: '会话记录', + }, }, }; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 6f9266c3..509071cb 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -887,6 +887,15 @@ const zhHant = { viewMonitoring: '查看日誌監控', refreshData: '重新整理資料', exportData: '匯出資料', + export: { + title: '匯出資料', + exporting: '匯出中...', + messages: '訊息記錄', + llmCalls: 'LLM 呼叫', + embeddingCalls: 'Embedding 呼叫', + errors: '錯誤日誌', + sessions: '會話記錄', + }, }, };