feat: support export message history

This commit is contained in:
WangCham
2026-02-08 10:19:27 +08:00
parent 59d55b382d
commit 6d858475d7
8 changed files with 691 additions and 15 deletions

View File

@@ -323,3 +323,100 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.error(message=f'Message {message_id} not found', code=404) return self.error(message=f'Message {message_id} not found', code=404)
return self.success(data=details) 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

View File

@@ -794,3 +794,324 @@ class MonitoringService:
}, },
'errors': errors, '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
]

View File

@@ -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<ExportType | null>(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: <FileText className="w-4 h-4 mr-2" />,
},
{
type: 'llm-calls',
label: t('monitoring.export.llmCalls'),
icon: <Database className="w-4 h-4 mr-2" />,
},
{
type: 'embedding-calls',
label: t('monitoring.export.embeddingCalls'),
icon: <Layers className="w-4 h-4 mr-2" />,
},
{
type: 'errors',
label: t('monitoring.export.errors'),
icon: <AlertCircle className="w-4 h-4 mr-2" />,
},
{
type: 'sessions',
label: t('monitoring.export.sessions'),
icon: <Users className="w-4 h-4 mr-2" />,
},
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
disabled={exporting !== null}
>
{exporting ? (
<>
<svg
className="w-4 h-4 mr-2 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{t('monitoring.export.exporting')}
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
{t('monitoring.exportData')}
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{t('monitoring.export.title')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{exportOptions.map((option) => (
<DropdownMenuItem
key={option.type}
onClick={() => handleExport(option.type)}
disabled={exporting !== null}
className="cursor-pointer"
>
{option.icon}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react'; import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards'; import OverviewCards from './components/overview-cards/OverviewCards';
import MonitoringFilters from './components/filters/MonitoringFilters'; import MonitoringFilters from './components/filters/MonitoringFilters';
import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters'; import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData'; import { useMonitoringData } from './hooks/useMonitoringData';
import { MessageDetailsCard } from './components/MessageDetailsCard'; import { MessageDetailsCard } from './components/MessageDetailsCard';
@@ -200,22 +201,25 @@ function MonitoringPageContent() {
onPipelinesChange={setSelectedPipelines} onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange} onTimeRangeChange={setTimeRange}
/> />
<Button <div className="flex items-center gap-2">
variant="outline" <ExportDropdown filterState={filterState} />
size="sm" <Button
onClick={refetch} variant="outline"
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0" size="sm"
> onClick={refetch}
<svg className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
> >
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path> <svg
</svg> className="w-4 h-4 mr-2"
{t('monitoring.refreshData')} xmlns="http://www.w3.org/2000/svg"
</Button> viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
{t('monitoring.refreshData')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -952,6 +952,15 @@ const enUS = {
viewMonitoring: 'View Monitoring', viewMonitoring: 'View Monitoring',
refreshData: 'Refresh Data', refreshData: 'Refresh Data',
exportData: 'Export Data', exportData: 'Export Data',
export: {
title: 'Export Data',
exporting: 'Exporting...',
messages: 'Messages',
llmCalls: 'LLM Calls',
embeddingCalls: 'Embedding Calls',
errors: 'Error Logs',
sessions: 'Sessions',
},
}, },
}; };

View File

@@ -939,6 +939,15 @@ const jaJP = {
viewMonitoring: 'モニタリングを表示', viewMonitoring: 'モニタリングを表示',
refreshData: 'データを更新', refreshData: 'データを更新',
exportData: 'データをエクスポート', exportData: 'データをエクスポート',
export: {
title: 'データをエクスポート',
exporting: 'エクスポート中...',
messages: 'メッセージ',
llmCalls: 'LLM コール',
embeddingCalls: 'Embedding コール',
errors: 'エラーログ',
sessions: 'セッション',
},
}, },
}; };

View File

@@ -912,6 +912,15 @@ const zhHans = {
viewMonitoring: '查看日志监控', viewMonitoring: '查看日志监控',
refreshData: '刷新数据', refreshData: '刷新数据',
exportData: '导出数据', exportData: '导出数据',
export: {
title: '导出数据',
exporting: '导出中...',
messages: '消息记录',
llmCalls: 'LLM 调用',
embeddingCalls: 'Embedding 调用',
errors: '错误日志',
sessions: '会话记录',
},
}, },
}; };

View File

@@ -887,6 +887,15 @@ const zhHant = {
viewMonitoring: '查看日誌監控', viewMonitoring: '查看日誌監控',
refreshData: '重新整理資料', refreshData: '重新整理資料',
exportData: '匯出資料', exportData: '匯出資料',
export: {
title: '匯出資料',
exporting: '匯出中...',
messages: '訊息記錄',
llmCalls: 'LLM 呼叫',
embeddingCalls: 'Embedding 呼叫',
errors: '錯誤日誌',
sessions: '會話記錄',
},
}, },
}; };