mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
* refactor(web): migrate from Next.js to Vite + React Router
* fix: update build pipelines for Vite migration (out → dist)
- Dockerfile: npm run build → npx vite build, web/out → web/dist
- pyproject.toml: package-data web/out/** → web/dist/**
- paths.py: support both web/dist (Vite) and web/out (legacy) with fallback
* fix: remove .next from git tracking, add to .gitignore
1334 cached files from web/.next/ were accidentally committed.
Added .next/ to both root and web/.gitignore.
* fix: update build process to use Vite and correct output directory
* fix: update pnpm-lock.yaml and eslint config for Vite migration
* style: fix prettier formatting issues
* fix: add eslint-plugin-react-hooks for Vite migration
* fix: remove undefined eslint rule reference, downgrade react-hooks plugin to v5
* fix(web): clean up remaining Next.js artifacts in Vite migration
- Add vite-env.d.ts for import.meta.env and asset type declarations
- Remove dead layout.tsx (providers already in main.tsx)
- Fix useSearchParams destructuring to [searchParams] tuple (11 locations)
- Replace process.env.NEXT_PUBLIC_* with import.meta.env.VITE_*
- Fix langbotIcon.src to langbotIcon (Vite returns URL string)
- Fix Link href to Link to for react-router-dom
- Fix navigate({ scroll: false }) to { preventScrollReset: true }
- Fix [router] dependency arrays to [navigate]
- Remove Next.js plugin from tsconfig, set rsc: false in components.json
- Replace next lint with eslint in lint-staged
* feat: add tools API endpoint and tools-selector form type
Backend:
- Add GET /api/v1/tools — list all available tools (plugin + MCP)
- Add GET /api/v1/tools/<tool_name> — get specific tool details
Frontend:
- Add TOOLS_SELECTOR form type for plugin config forms
- Multi-select dialog with tool name and description
- Add PluginTool entity type and API client methods
* Revert "feat: add tools API endpoint and tools-selector form type"
This reverts commit 3c637fc563.
258 lines
8.1 KiB
TypeScript
258 lines
8.1 KiB
TypeScript
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-card rounded-xl border p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
|
<div className="flex gap-4">
|
|
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
|
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
|
</div>
|
|
</div>
|
|
<div className="h-[300px] flex items-center justify-center">
|
|
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (chartData.length === 0) {
|
|
return (
|
|
<div className="bg-card rounded-xl border p-6">
|
|
<h3 className="text-base font-semibold text-foreground mb-4">
|
|
{t('monitoring.trafficChart.title')}
|
|
</h3>
|
|
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
|
<svg
|
|
className="h-[3rem] w-[3rem]"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
>
|
|
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
|
|
</svg>
|
|
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
|
|
<h3 className="text-base font-semibold text-foreground 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="var(--border)"
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
dataKey="time"
|
|
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: 'var(--border)' }}
|
|
dy={10}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
|
tickLine={false}
|
|
axisLine={{ stroke: 'var(--border)' }}
|
|
width={40}
|
|
allowDecimals={false}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: 'var(--card)',
|
|
border: '1px solid var(--border)',
|
|
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',
|
|
color: 'var(--foreground)',
|
|
}}
|
|
labelStyle={{
|
|
fontWeight: 600,
|
|
marginBottom: '8px',
|
|
color: 'var(--foreground)',
|
|
}}
|
|
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>
|
|
);
|
|
}
|