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,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>
);
}