forked from 77media/video-flow
- 新增任务统计数据接口和消息队列状态接口 - 添加任务监控页面 (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 接口,支持项目任务统计和消息队列管理
286 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|