video-flow-b/components/task-monitor/task-timeline-chart.tsx
qikongjian 9258f50f8e feat: 添加任务监控功能
- 新增任务统计数据接口和消息队列状态接口
- 添加任务监控页面 (app/task-monitor/page.tsx)
- 实现任务监控相关组件:
  - 项目选择器 (project-selector.tsx)
  - 任务统计卡片 (task-statistics-cards.tsx)
  - 任务状态图表 (task-status-chart.tsx)
  - 任务时间线图表 (task-timeline-chart.tsx)
  - 任务详情表格 (task-detail-table.tsx)
  - 消息队列面板 (message-queue-panel.tsx)
- 扩展 video_flow.ts API 接口,支持项目任务统计和消息队列管理
2025-08-26 22:03:57 +08:00

286 lines
9.6 KiB
TypeScript

'use client';
import React, { useState, useEffect } from 'react';
interface TaskTimelineData {
timestamp: string;
completed: number;
failed: number;
in_progress: number;
total: number;
}
interface TaskTimelineChartProps {
projectIds: string[];
timeRange: number; // 小时
}
export default function TaskTimelineChart({ projectIds, timeRange }: TaskTimelineChartProps) {
const [timelineData, setTimelineData] = useState<TaskTimelineData[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMetric, setSelectedMetric] = useState<'total' | 'completed' | 'failed' | 'success_rate'>('total');
// 模拟生成时间线数据
const generateMockData = () => {
const data: TaskTimelineData[] = [];
const now = new Date();
const intervalHours = timeRange <= 24 ? 1 : timeRange <= 72 ? 3 : 6;
const points = Math.floor(timeRange / intervalHours);
for (let i = points; i >= 0; i--) {
const timestamp = new Date(now.getTime() - i * intervalHours * 60 * 60 * 1000);
// 模拟数据波动
const baseCompleted = 50 + Math.random() * 30;
const baseFailed = 5 + Math.random() * 10;
const baseInProgress = 10 + Math.random() * 15;
// 根据项目数量调整
const multiplier = projectIds.length;
data.push({
timestamp: timestamp.toISOString(),
completed: Math.floor(baseCompleted * multiplier),
failed: Math.floor(baseFailed * multiplier),
in_progress: Math.floor(baseInProgress * multiplier),
total: Math.floor((baseCompleted + baseFailed + baseInProgress) * multiplier),
});
}
return data;
};
useEffect(() => {
setLoading(true);
// 模拟API调用
setTimeout(() => {
setTimelineData(generateMockData());
setLoading(false);
}, 500);
}, [projectIds, timeRange]);
const getMetricValue = (data: TaskTimelineData, metric: string) => {
switch (metric) {
case 'total':
return data.total;
case 'completed':
return data.completed;
case 'failed':
return data.failed;
case 'success_rate':
return data.total > 0 ? (data.completed / data.total) * 100 : 0;
default:
return 0;
}
};
const getMetricColor = (metric: string) => {
switch (metric) {
case 'total':
return '#3b82f6';
case 'completed':
return '#10b981';
case 'failed':
return '#ef4444';
case 'success_rate':
return '#8b5cf6';
default:
return '#6b7280';
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
if (timeRange <= 24) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
};
const LineChart = () => {
if (timelineData.length === 0) return null;
const values = timelineData.map(d => getMetricValue(d, selectedMetric));
const maxValue = Math.max(...values);
const minValue = Math.min(...values);
const range = maxValue - minValue || 1;
const width = 600;
const height = 200;
const padding = 40;
const points = timelineData.map((data, index) => {
const x = padding + (index / (timelineData.length - 1)) * (width - 2 * padding);
const value = getMetricValue(data, selectedMetric);
const y = height - padding - ((value - minValue) / range) * (height - 2 * padding);
return { x, y, value, data };
});
const pathData = points.map((point, index) =>
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
).join(' ');
return (
<div className="relative">
<svg width={width} height={height} className="overflow-visible">
{/* 网格线 */}
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 40" fill="none" stroke="#374151" strokeWidth="0.5" opacity="0.3"/>
</pattern>
</defs>
<rect width={width} height={height} fill="url(#grid)" />
{/* Y轴标签 */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => {
const y = height - padding - ratio * (height - 2 * padding);
const value = minValue + ratio * range;
return (
<g key={index}>
<line x1={padding} y1={y} x2={width - padding} y2={y} stroke="#374151" strokeWidth="0.5" opacity="0.5" />
<text x={padding - 10} y={y + 4} fill="#9ca3af" fontSize="12" textAnchor="end">
{selectedMetric === 'success_rate' ? `${value.toFixed(0)}%` : Math.round(value)}
</text>
</g>
);
})}
{/* 主线条 */}
<path
d={pathData}
fill="none"
stroke={getMetricColor(selectedMetric)}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 填充区域 */}
<path
d={`${pathData} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`}
fill={getMetricColor(selectedMetric)}
fillOpacity="0.1"
/>
{/* 数据点 */}
{points.map((point, index) => (
<circle
key={index}
cx={point.x}
cy={point.y}
r="4"
fill={getMetricColor(selectedMetric)}
stroke="white"
strokeWidth="2"
className="hover:r-6 transition-all cursor-pointer"
>
<title>
{formatTimestamp(point.data.timestamp)}: {
selectedMetric === 'success_rate'
? `${point.value.toFixed(1)}%`
: Math.round(point.value)
}
</title>
</circle>
))}
{/* X轴标签 */}
{points.filter((_, index) => index % Math.ceil(points.length / 6) === 0).map((point, index) => (
<text
key={index}
x={point.x}
y={height - 10}
fill="#9ca3af"
fontSize="12"
textAnchor="middle"
>
{formatTimestamp(point.data.timestamp)}
</text>
))}
</svg>
</div>
);
};
const metrics = [
{ key: 'total', label: '总任务数', color: '#3b82f6' },
{ key: 'completed', label: '完成数', color: '#10b981' },
{ key: 'failed', label: '失败数', color: '#ef4444' },
{ key: 'success_rate', label: '成功率', color: '#8b5cf6' },
];
return (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white"></h3>
<div className="flex items-center gap-2">
{metrics.map((metric) => (
<button
key={metric.key}
onClick={() => setSelectedMetric(metric.key as any)}
className={`px-3 py-1 rounded text-sm transition-colors ${
selectedMetric === metric.key
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{metric.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="text-center text-gray-400">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
<div>...</div>
</div>
</div>
) : timelineData.length === 0 ? (
<div className="flex items-center justify-center h-48 text-gray-400">
<div className="text-center">
<div className="text-4xl mb-2">📈</div>
<div></div>
</div>
</div>
) : (
<div>
<LineChart />
{/* 统计信息 */}
<div className="mt-6 pt-4 border-t border-gray-700">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{metrics.map((metric) => {
const values = timelineData.map(d => getMetricValue(d, metric.key));
const latest = values[values.length - 1] || 0;
const previous = values[values.length - 2] || 0;
const change = previous !== 0 ? ((latest - previous) / previous) * 100 : 0;
return (
<div key={metric.key} className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: metric.color }}
/>
<span className="text-gray-300">{metric.label}</span>
</div>
<div className="text-lg font-bold text-white">
{metric.key === 'success_rate' ? `${latest.toFixed(1)}%` : Math.round(latest)}
</div>
<div className={`text-xs ${change >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{change >= 0 ? '↗' : '↘'} {Math.abs(change).toFixed(1)}%
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}