From 6d858475d7007bf584361eef03e31cf24aa403e1 Mon Sep 17 00:00:00 2001 From: WangCham <651122857@qq.com> Date: Sun, 8 Feb 2026 10:19:27 +0800 Subject: [PATCH 1/6] feat: support export message history --- .../api/http/controller/groups/monitoring.py | 97 ++++++ .../pkg/api/http/service/monitoring.py | 321 ++++++++++++++++++ .../monitoring/components/ExportDropdown.tsx | 218 ++++++++++++ web/src/app/home/monitoring/page.tsx | 34 +- web/src/i18n/locales/en-US.ts | 9 + web/src/i18n/locales/ja-JP.ts | 9 + web/src/i18n/locales/zh-Hans.ts | 9 + web/src/i18n/locales/zh-Hant.ts | 9 + 8 files changed, 691 insertions(+), 15 deletions(-) create mode 100644 web/src/app/home/monitoring/components/ExportDropdown.tsx diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index a1964a4b..c8f7bc76 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -323,3 +323,100 @@ 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..b3f90f61 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -794,3 +794,324 @@ 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 '' + # 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/src/app/home/monitoring/components/ExportDropdown.tsx b/web/src/app/home/monitoring/components/ExportDropdown.tsx new file mode 100644 index 00000000..d7e8adce --- /dev/null +++ b/web/src/app/home/monitoring/components/ExportDropdown.tsx @@ -0,0 +1,218 @@ +'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, TimeRangeOption } from '../types/monitoring'; +import { DateRange, dateUtils } from '../utils/dateUtils'; + +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 instance to get the auth token + const response = await backendClient.instance.get( + `/api/v1/monitoring/export?${params.toString()}`, + { + responseType: 'blob', + }, + ); + + // 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/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/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: '會話記錄', + }, }, }; From d2d7892325ed052fabf50a4e58f166db03eec4bf Mon Sep 17 00:00:00 2001 From: wangcham Date: Mon, 9 Feb 2026 00:36:30 +0800 Subject: [PATCH 2/6] fix: lint --- web/.prettierrc.mjs | 1 - .../monitoring/components/overview-cards/TrafficChart.tsx | 5 ++++- .../components/plugin-installed/PluginInstalledComponent.tsx | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) 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/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/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', + )}`}
From c2574bdd3af6499a1f942848ced505ecc811ed13 Mon Sep 17 00:00:00 2001 From: wangcham Date: Mon, 9 Feb 2026 01:01:20 +0800 Subject: [PATCH 3/6] fix: lint error --- .../api/http/controller/groups/monitoring.py | 94 ++++++++++++++++--- .../pkg/api/http/service/monitoring.py | 9 +- .../monitoring/components/ExportDropdown.tsx | 22 ++++- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index c8f7bc76..3b9f1e08 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -348,9 +348,22 @@ class MonitoringRouterGroup(group.RouterGroup): 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'] + 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, @@ -359,18 +372,46 @@ class MonitoringRouterGroup(group.RouterGroup): 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'] + 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'] + 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, @@ -379,8 +420,19 @@ class MonitoringRouterGroup(group.RouterGroup): 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'] + 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, @@ -389,9 +441,19 @@ class MonitoringRouterGroup(group.RouterGroup): 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'] + 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) @@ -417,6 +479,8 @@ class MonitoringRouterGroup(group.RouterGroup): # 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"' + 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 b3f90f61..93ad981a 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -820,6 +820,7 @@ class MonitoringService: try: import json + message_chain = json.loads(message_content) if not isinstance(message_chain, list): return message_content @@ -909,7 +910,9 @@ class MonitoringService: '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), + '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, @@ -1108,7 +1111,9 @@ class MonitoringService: '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), + '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, diff --git a/web/src/app/home/monitoring/components/ExportDropdown.tsx b/web/src/app/home/monitoring/components/ExportDropdown.tsx index d7e8adce..4efabd43 100644 --- a/web/src/app/home/monitoring/components/ExportDropdown.tsx +++ b/web/src/app/home/monitoring/components/ExportDropdown.tsx @@ -2,7 +2,14 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Download, FileText, Database, AlertCircle, Users, Layers } from 'lucide-react'; +import { + Download, + FileText, + Database, + AlertCircle, + Users, + Layers, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -13,8 +20,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { backendClient } from '@/app/infra/http'; -import { FilterState, TimeRangeOption } from '../types/monitoring'; -import { DateRange, dateUtils } from '../utils/dateUtils'; +import { FilterState } from '../types/monitoring'; export type ExportType = | 'messages' @@ -104,7 +110,9 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) { const contentDisposition = response.headers['content-disposition']; let filename = `monitoring-${type}-${Date.now()}.csv`; if (contentDisposition) { - const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/); + const filenameMatch = contentDisposition.match( + /filename="?([^";\n]+)"?/, + ); if (filenameMatch) { filename = filenameMatch[1]; } @@ -129,7 +137,11 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) { } }; - const exportOptions: { type: ExportType; label: string; icon: React.ReactNode }[] = [ + const exportOptions: { + type: ExportType; + label: string; + icon: React.ReactNode; + }[] = [ { type: 'messages', label: t('monitoring.export.messages'), From 10dd8c86d0561244ba41bd5fa9bddb931d35c16c Mon Sep 17 00:00:00 2001 From: wangcham Date: Mon, 9 Feb 2026 10:48:22 +0800 Subject: [PATCH 4/6] fix: frontend lint --- .../home/monitoring/components/ExportDropdown.tsx | 7 ++----- web/src/app/infra/http/BaseHttpClient.ts | 15 +++++++++++++++ web/tsconfig.json | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/web/src/app/home/monitoring/components/ExportDropdown.tsx b/web/src/app/home/monitoring/components/ExportDropdown.tsx index 4efabd43..b0a537e0 100644 --- a/web/src/app/home/monitoring/components/ExportDropdown.tsx +++ b/web/src/app/home/monitoring/components/ExportDropdown.tsx @@ -98,12 +98,9 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) { }); } - // Use backendClient's instance to get the auth token - const response = await backendClient.instance.get( + // Use backendClient's downloadFile method for blob response + const response = await backendClient.downloadFile( `/api/v1/monitoring/export?${params.toString()}`, - { - responseType: 'blob', - }, ); // Get filename from content-disposition header 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/tsconfig.json b/web/tsconfig.json index b575f7da..5d606a9f 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { From 9ce3ad8300412e57c68a9662f2c9761067762c0d Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 15 Feb 2026 15:07:35 +0800 Subject: [PATCH 5/6] fix: update JSX setting in TypeScript configuration to use react-jsx --- web/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tsconfig.json b/web/tsconfig.json index 5d606a9f..b575f7da 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { From b7e1e43fbd0a39020d35f4ae6dc169f498bca15d Mon Sep 17 00:00:00 2001 From: wangcham Date: Tue, 17 Feb 2026 22:21:53 +0800 Subject: [PATCH 6/6] fix: some errors --- src/langbot/pkg/api/http/service/monitoring.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index 93ad981a..b9983519 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -801,6 +801,9 @@ class MonitoringService: """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