Feat/monitor (#1928)

* feat: add monitor

* feat: fix tab

* feat: work

* feat: not reliable monitor

* feat: enhance monitoring page layout with integrated filters and refresh button

* feat: add support for runner recording

* feat: add jump button & alignment

* feat: new

* fix: not show query variables in local agent

* fix: pnpm lint and python ruff check

* fix: ruff fromat

* chore: remove unnecessary migration

* style: optimize monitoring page layout and fix sticky filter issues

- Enhanced metric cards with gradient backgrounds and hover effects
- Increased traffic chart height from 200px to 300px
- Adjusted grid layout and spacing for better visual appeal
- Fixed sticky filter area to properly cover parent padding without transparent gaps
- Used negative margins and positioning to eliminate scrolling artifacts
- Matched padding/margins with other pages (pipelines, bots) for consistency
- Removed duplicate title/subtitle from page content
- Added cursor-pointer styling to tab triggers
- Removed border between tab list and tab content

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: apply prettier formatting to monitoring components

- Fixed indentation and spacing in MetricCard.tsx
- Fixed formatting in TrafficChart.tsx
- Applied prettier formatting to page.tsx

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: update HomeSidebar to trigger action on child selection and localize monitoring titles

* refactor: streamline LLM and embedding invocation methods

* feat: add embedding model monitor

* fix: database version

* chore: simplify pnpm-lock.yaml formatting

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Guanchao Wang
2026-01-26 21:08:23 +08:00
committed by GitHub
parent b73847f1a6
commit 5d9f6ec763
37 changed files with 6706 additions and 3182 deletions
@@ -0,0 +1,230 @@
'use client';
import React, { useState } from 'react';
import {
MessageChainComponent,
Image as ImageComponent,
Plain,
At,
Voice,
Quote,
} from '@/app/infra/entities/message';
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
interface MessageContentRendererProps {
content: string;
maxLines?: number;
}
export function MessageContentRenderer({
content,
maxLines = 3,
}: MessageContentRendererProps) {
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
// Try to parse content as message_chain JSON
const parseContent = (content: string): MessageChainComponent[] | null => {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].type) {
return parsed as MessageChainComponent[];
}
return null;
} catch {
return null;
}
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
) => {
switch (component.type) {
case 'Plain':
return <span key={index}>{(component as Plain).text}</span>;
case 'At': {
const atComponent = component as At;
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@{displayName}
</span>
);
}
case 'AtAll':
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@All
</span>
);
case 'Image': {
const img = component as ImageComponent;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) {
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
>
[Image]
</span>
);
}
return (
<span key={index} className="inline-block align-middle mx-1">
<img
src={imageUrl}
alt="Image"
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border border-gray-200 dark:border-gray-700"
onClick={(e) => {
e.stopPropagation();
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</span>
);
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
</svg>
{file.name || 'File'}
</span>
);
}
case 'Voice': {
const voice = component as Voice;
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm border-l-2 border-gray-400"
>
{quote.origin
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
.map((c) => (c as MessageChainComponent as Plain).text)
.join('') || '[Quote]'}
</span>
);
}
case 'Source':
return null;
default:
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
>
[{component.type}]
</span>
);
}
};
const messageChain = parseContent(content);
// Determine line clamp class
const lineClampClass =
maxLines === 2
? 'line-clamp-2'
: maxLines === 3
? 'line-clamp-3'
: maxLines === 4
? 'line-clamp-4'
: '';
if (messageChain) {
// Filter out Source components as they render to null
const visibleComponents = messageChain.filter(
(component) => component.type !== 'Source',
);
// If no visible components, show placeholder
if (visibleComponents.length === 0) {
return (
<span className="text-gray-400 dark:text-gray-500 italic">
[Empty message]
</span>
);
}
// Render as message chain
return (
<>
<div className={`${lineClampClass}`}>
{messageChain.map((component, index) =>
renderMessageComponent(component, index),
)}
</div>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
);
}
// Handle empty plain text
if (
!content ||
content.trim() === '' ||
content === '[]' ||
content === '""'
) {
return (
<span className="text-gray-400 dark:text-gray-500 italic">
[Empty message]
</span>
);
}
// Render as plain text
return <span className={lineClampClass}>{content}</span>;
}
@@ -0,0 +1,292 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
details: MessageDetails;
}
export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
const { t } = useTranslation();
// Parse query variables JSON string
const queryVariables = useMemo(() => {
if (!details.message?.variables) return null;
try {
return JSON.parse(details.message.variables);
} catch {
return null;
}
}, [details.message?.variables]);
return (
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
{/* Context Info Section */}
{details.message && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z"></path>
</svg>
{t('monitoring.messageList.viewDetails')}
</h4>
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{details.message.platform && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.platform')}
</div>
<div className="font-medium text-gray-900 dark:text-white">
{details.message.platform}
</div>
</div>
)}
{details.message.userId && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.user')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{details.message.userId}
</div>
</div>
)}
{details.message.runnerName && (
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.runner')}
</div>
<div className="font-medium text-gray-900 dark:text-white">
{details.message.runnerName}
</div>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded p-2">
<div className="text-gray-500 dark:text-gray-400">
{t('monitoring.messageList.level')}
</div>
<div
className={`font-medium ${
details.message.level === 'error'
? 'text-red-600 dark:text-red-400'
: details.message.level === 'warning'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-gray-900 dark:text-white'
}`}
>
{details.message.level.toUpperCase()}
</div>
</div>
</div>
</div>
)}
{/* LLM Calls Section */}
{details.llmCalls && details.llmCalls.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C16.42 20 20 16.42 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM13 12V7H11V14H17V12H13Z"></path>
</svg>
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
</h4>
{/* LLM Stats Summary */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-blue-50 dark:bg-blue-900/30 rounded p-2">
<div className="text-xs text-blue-600 dark:text-blue-400">
{t('monitoring.llmCalls.totalTokens')}
</div>
<div className="text-lg font-semibold text-blue-900 dark:text-blue-100">
{details.llmStats.totalTokens.toLocaleString()}
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/30 rounded p-2">
<div className="text-xs text-green-600 dark:text-green-400">
{t('monitoring.llmCalls.avgDuration')}
</div>
<div className="text-lg font-semibold text-green-900 dark:text-green-100">
{details.llmStats.averageDurationMs}ms
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/30 rounded p-2">
<div className="text-xs text-purple-600 dark:text-purple-400">
{t('monitoring.llmCalls.calls')}
</div>
<div className="text-lg font-semibold text-purple-900 dark:text-purple-100">
{details.llmStats.totalCalls}
</div>
</div>
</div>
{/* Individual LLM Calls */}
<div className="space-y-2">
{details.llmCalls.map((call, index) => (
<div
key={call.id}
className="bg-white dark:bg-gray-900 rounded p-2 text-sm"
>
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium text-gray-900 dark:text-white">
#{index + 1} {call.modelName}
</span>
<span
className={`ml-2 text-xs px-2 py-0.5 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{call.duration}ms
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400">
<div>
<span className="text-gray-500 dark:text-gray-500">
In:
</span>{' '}
{call.tokens.input.toLocaleString()}
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">
Out:
</span>{' '}
{call.tokens.output.toLocaleString()}
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">
Total:
</span>{' '}
{call.tokens.total.toLocaleString()}
</div>
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
{call.errorMessage}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Errors Section */}
{details.errors && details.errors.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z"></path>
</svg>
{t('monitoring.errors.title')} ({details.errors.length})
</h4>
<div className="space-y-2">
{details.errors.map((error) => (
<div
key={error.id}
className="bg-red-50 dark:bg-red-900/20 rounded p-2 text-sm"
>
<div className="font-medium text-red-900 dark:text-red-300 mb-1">
{error.errorType}
</div>
<div className="text-red-700 dark:text-red-400 text-xs mb-2">
{error.errorMessage}
</div>
{error.stackTrace && (
<details className="text-xs">
<summary className="cursor-pointer text-red-600 dark:text-red-500 hover:text-red-800 dark:hover:text-red-300">
{t('monitoring.errors.stackTrace')}
</summary>
<pre className="mt-2 p-2 bg-red-100 dark:bg-red-900/40 rounded overflow-x-auto text-xs">
{error.stackTrace}
</pre>
</details>
)}
</div>
))}
</div>
</div>
)}
{/* Query Variables Section - Only show for non-local-agent runners */}
{queryVariables &&
Object.keys(queryVariables).length > 0 &&
details.message?.runnerName !== 'local-agent' && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V9.7C6 10.7065 5.41099 11.5849 4.55132 12C5.41099 12.4151 6 13.2935 6 14.3V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V14.3C18 13.2935 18.589 12.4151 19.4487 12C18.589 11.5849 18 10.7065 18 9.7V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z"></path>
</svg>
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => (
<div
key={key}
className="bg-white dark:bg-gray-900 rounded p-2"
>
<div className="text-gray-500 dark:text-gray-400">{key}</div>
<div
className="font-medium text-gray-900 dark:text-white truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-gray-400 italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-gray-400 italic">empty</span>
)
) : (
JSON.stringify(value)
)}
</div>
</div>
))}
</div>
</div>
)}
{/* No data message */}
{(!details.llmCalls || details.llmCalls.length === 0) &&
(!details.errors || details.errors.length === 0) &&
(details.message?.runnerName === 'local-agent' ||
!queryVariables ||
Object.keys(queryVariables).length === 0) && (
<div className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
{t('monitoring.messageDetails.noData')}
</div>
)}
</div>
);
}
@@ -0,0 +1,209 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { backendClient } from '@/app/infra/http';
import { TimeRangeOption } from '../../types/monitoring';
interface MonitoringFiltersProps {
selectedBots: string[];
selectedPipelines: string[];
timeRange: TimeRangeOption;
onBotsChange: (bots: string[]) => void;
onPipelinesChange: (pipelines: string[]) => void;
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
}
interface Bot {
uuid: string;
name: string;
}
interface Pipeline {
uuid: string;
name: string;
}
export default function MonitoringFilters({
selectedBots,
selectedPipelines,
timeRange,
onBotsChange,
onPipelinesChange,
onTimeRangeChange,
}: MonitoringFiltersProps) {
const { t } = useTranslation();
const [bots, setBots] = useState<Bot[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [loadingBots, setLoadingBots] = useState(false);
const [loadingPipelines, setLoadingPipelines] = useState(false);
// Fetch bots list
useEffect(() => {
const fetchBots = async () => {
setLoadingBots(true);
try {
const response = await backendClient.getBots();
// Filter out bots without uuid and map to local Bot interface
const validBots = (response.bots || [])
.filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)
.map((bot) => ({ uuid: bot.uuid, name: bot.name }));
setBots(validBots);
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoadingBots(false);
}
};
fetchBots();
}, []);
// Fetch pipelines list
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true);
try {
const response = await backendClient.getPipelines();
// Filter out pipelines without uuid and map to local Pipeline interface
const validPipelines = (response.pipelines || [])
.filter(
(pipeline): pipeline is typeof pipeline & { uuid: string } =>
!!pipeline.uuid,
)
.map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));
setPipelines(validPipelines);
} catch (error) {
console.error('Failed to fetch pipelines:', error);
} finally {
setLoadingPipelines(false);
}
};
fetchPipelines();
}, []);
const handleBotChange = (value: string) => {
if (value === 'all') {
onBotsChange([]);
} else {
onBotsChange([value]);
}
};
const handlePipelineChange = (value: string) => {
if (value === 'all') {
onPipelinesChange([]);
} else {
onPipelinesChange([value]);
}
};
const handleTimeRangeChange = (value: string) => {
onTimeRangeChange(value as TimeRangeOption);
};
return (
<div className="flex flex-wrap items-center gap-6">
{/* Bot Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('monitoring.filters.bot')}
</label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Pipeline Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('monitoring.filters.pipeline')}
</label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingPipelines}
>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
<SelectValue
placeholder={
loadingPipelines
? t('monitoring.filters.loading')
: t('monitoring.filters.selectPipeline')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allPipelines')}
</SelectItem>
{pipelines.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
{pipeline.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Time Range Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('monitoring.filters.timeRange')}
</label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lastHour">
{t('monitoring.filters.lastHour')}
</SelectItem>
<SelectItem value="last6Hours">
{t('monitoring.filters.last6Hours')}
</SelectItem>
<SelectItem value="last24Hours">
{t('monitoring.filters.last24Hours')}
</SelectItem>
<SelectItem value="last7Days">
{t('monitoring.filters.last7Days')}
</SelectItem>
<SelectItem value="last30Days">
{t('monitoring.filters.last30Days')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
interface MetricCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
trend?: {
value: number;
direction: 'up' | 'down';
};
loading?: boolean;
}
export default function MetricCard({
title,
value,
icon,
trend,
loading,
}: MetricCardProps) {
if (loading) {
return (
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
{title}
</CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">
{icon}
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
<div className="h-4 w-20 bg-gray-100 dark:bg-gray-800 animate-pulse rounded mt-2"></div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
{title}
</CardTitle>
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">{icon}</div>
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{value}
</div>
{trend && (
<div className="flex items-center gap-1.5">
<span
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${
trend.direction === 'up'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
{trend.direction === 'up' ? (
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
)}
</svg>
{Math.abs(trend.value)}%
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
vs previous period
</span>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,135 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import MetricCard from './MetricCard';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
MonitoringMessage,
LLMCall,
} from '../../types/monitoring';
interface OverviewCardsProps {
metrics: OverviewMetrics | null;
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
}
export default function OverviewCards({
metrics,
messages = [],
llmCalls = [],
loading,
}: OverviewCardsProps) {
const { t } = useTranslation();
const cards = [
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
),
trend: metrics?.trends
? {
value: metrics.trends.messages,
direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
),
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 15.172L19.192 5.979L20.607 7.393L10 18L3.636 11.636L5.05 10.222L10 15.172Z"></path>
</svg>
),
trend: metrics?.trends
? {
value: metrics.trends.successRate,
direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.7519 23 22H21C21 19.3742 19.4041 17.1096 17.1582 16.2466L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
),
trend: metrics?.trends
? {
value: metrics.trends.sessions,
direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
];
return (
<div className="space-y-6">
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{cards.map((card, index) => (
<MetricCard
key={index}
title={card.title}
value={card.value}
icon={card.icon}
trend={card.trend}
loading={loading}
/>
))}
</div>
{/* Traffic Chart */}
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
</div>
);
}
@@ -0,0 +1,263 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
interface TrafficChartProps {
messages: MonitoringMessage[];
llmCalls: LLMCall[];
loading?: boolean;
}
interface ChartDataPoint {
time: string;
timestamp: number;
messages: number;
llmCalls: number;
}
export default function TrafficChart({
messages,
llmCalls,
loading,
}: TrafficChartProps) {
const { t } = useTranslation();
const chartData = useMemo(() => {
if (!messages.length && !llmCalls.length) {
return [];
}
// Combine all timestamps and find the range
const allTimestamps = [
...messages.map((m) => m.timestamp.getTime()),
...llmCalls.map((c) => c.timestamp.getTime()),
];
if (allTimestamps.length === 0) return [];
const minTime = Math.min(...allTimestamps);
const maxTime = Math.max(...allTimestamps);
const timeRange = maxTime - minTime;
// Determine bucket size based on time range
let bucketSize: number;
let formatTime: (date: Date) => string;
if (timeRange <= 60 * 60 * 1000) {
// <= 1 hour: 5-minute buckets
bucketSize = 5 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 6 * 60 * 60 * 1000) {
// <= 6 hours: 15-minute buckets
bucketSize = 15 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 24 * 60 * 60 * 1000) {
// <= 24 hours: 1-hour buckets
bucketSize = 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
// <= 7 days: 4-hour buckets
bucketSize = 4 * 60 * 60 * 1000;
formatTime = (date) =>
`${date.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
} else {
// > 7 days: 1-day buckets
bucketSize = 24 * 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Create buckets
const buckets: Map<number, ChartDataPoint> = new Map();
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
buckets.set(bucket, {
time: formatTime(new Date(bucket)),
timestamp: bucket,
messages: 0,
llmCalls: 0,
});
}
// Count messages per bucket
messages.forEach((msg) => {
const bucket =
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.messages++;
}
});
// Count LLM calls per bucket
llmCalls.forEach((call) => {
const bucket =
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.llmCalls++;
}
});
return Array.from(buckets.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [messages, llmCalls]);
if (loading) {
return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
<div className="flex gap-4">
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
</div>
</div>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse w-full h-full bg-gray-100 dark:bg-gray-800 rounded"></div>
</div>
</div>
);
}
if (chartData.length === 0) {
return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-4">
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<svg
className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p className="text-sm font-medium">
{t('monitoring.trafficChart.noData')}
</p>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6 hover:shadow-md transition-shadow duration-300">
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-6">
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorMessages" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorLLMCalls" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e5e7eb"
className="dark:stroke-gray-700"
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
dy={10}
/>
<YAxis
tick={{ fontSize: 12, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
width={40}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: '1px solid #e5e7eb',
borderRadius: '12px',
boxShadow:
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
fontSize: '13px',
padding: '12px',
}}
labelStyle={{
fontWeight: 600,
marginBottom: '8px',
color: '#374151',
}}
itemStyle={{ padding: '4px 0' }}
/>
<Legend
wrapperStyle={{
fontSize: '13px',
paddingTop: '16px',
fontWeight: 500,
}}
iconType="circle"
iconSize={10}
/>
<Area
type="monotone"
dataKey="messages"
name={t('monitoring.trafficChart.messages')}
stroke="#3b82f6"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorMessages)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
<Area
type="monotone"
dataKey="llmCalls"
name={t('monitoring.trafficChart.llmCalls')}
stroke="#8b5cf6"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorLLMCalls)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}